build(linux): carry the pinned Node inside the AppImage so serve's slot works offline (#28204)

* build(linux): carry the pinned Node inside the AppImage so orca serve's slot works offline

Linux packaging places the pinned glibc Node at resources/node-runtime/bin/node before the
glibc-floor gate, proves it serves one PTY with the packaged daemon entry, and deb/rpm exclude
it through fpm. orca serve's orcad slot copies that Node (hash-checked) instead of downloading.

* test(serve): type the bundled Node fixture

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-13 00:39:39 -07:00
committed by GitHub
co-authored by m4air
parent e9285f0f58
commit 49a1129aa6
10 changed files with 353 additions and 10 deletions
+13
View File
@@ -1173,6 +1173,19 @@ jobs:
rpm_marker="$(rpm2cpio "$rpm" | cpio --quiet --extract --to-stdout ./opt/Orca/resources/package-type)"
[[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; }
[[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; }
# Only the AppImage carries the pinned Node (config/scripts/appimage-node-runtime.cjs).
if dpkg-deb --fsys-tarfile "$deb" | tar -t | grep '/resources/node-runtime' >/dev/null; then
echo "The deb must not carry the AppImage's Node"; exit 1
fi
if rpm2cpio "$rpm" | cpio --quiet -t | grep '/resources/node-runtime' >/dev/null; then
echo "The rpm must not carry the AppImage's Node"; exit 1
fi
appimage_root="$(mktemp -d)"
(cd "$appimage_root" && "$GITHUB_WORKSPACE/dist/orca-linux.AppImage" --appimage-extract 'resources/node-runtime/bin/node' >/dev/null)
test -x "$appimage_root/squashfs-root/resources/node-runtime/bin/node"
"$appimage_root/squashfs-root/resources/node-runtime/bin/node" --version
rm -rf "$appimage_root"
ls -l dist/orca-linux.AppImage "$deb" "$rpm"
- wait: shutdown-fixture-cache
+19 -5
View File
@@ -55,6 +55,11 @@ const {
} = require('./scripts/packaged-orcad-template.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs')
const {
appImageNodeRuntimeFpmArgs,
placeAppImageNodeRuntime,
verifyAppImageNodeRuntimeServesPty
} = require('./scripts/appimage-node-runtime.cjs')
const { signWindowsUninstallerViaSignPath } = require('./scripts/windows-uninstaller-signing.cjs')
// Why: dev-channel builds must carry the *release* identity — same bundle id,
@@ -440,13 +445,15 @@ module.exports = {
// Why after the prune: `pnpm install:release` widens the CPU set for cross-builds,
// so an arm64 slice can still carry the x64 @parcel/watcher until
// prunePackagedRuntimeNodeModules drops it.
let appImageNodeRuntime = null
if (context.electronPlatformName === 'linux') {
const targetArch = { 1: 'x64', 3: 'arm64' }[context.arch]
// Before the floor gate, so the bundled Node is held to the same glibc floor.
appImageNodeRuntime = await placeAppImageNodeRuntime(resourcesDir, targetArch)
// Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary,
// so a cross-built slice could ship the host's pty.node and only fail at runtime.
finalizePackagedWaylandClipboard(resourcesDir)
verifyLinuxGlibcFloor(context.appOutDir, {
targetArch: { 1: 'x64', 3: 'arm64' }[context.arch]
})
verifyLinuxGlibcFloor(context.appOutDir, { targetArch })
}
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
@@ -471,6 +478,9 @@ module.exports = {
}
if (canExecuteTargetArch) {
verifyPackagedDaemonEntryBoots(resourcesDir)
if (appImageNodeRuntime) {
await verifyAppImageNodeRuntimeServesPty(resourcesDir, appImageNodeRuntime)
}
} else {
// Why: a cross-arch slice can't be booted by the host Node, but the
// unpacked entry must still exist — its absence is a layout regression
@@ -740,7 +750,9 @@ module.exports = {
// GUI-triggered and can never run on a server, so without this the CLI is
// unreachable from the shell on exactly the hosts that need it.
afterInstall: 'resources/linux/packaging/after-install.sh',
afterRemove: 'resources/linux/packaging/after-remove.sh'
afterRemove: 'resources/linux/packaging/after-remove.sh',
// Why: only an AppImage's files can vanish under a running daemon (see appimage-node-runtime.cjs).
fpm: appImageNodeRuntimeFpmArgs
},
rpm: {
packageName: 'orca-ide',
@@ -757,7 +769,9 @@ module.exports = {
],
// Why: same headless CLI-on-PATH registration as deb; rpm runs these via fpm.
afterInstall: 'resources/linux/packaging/after-install.sh',
afterRemove: 'resources/linux/packaging/after-remove.sh'
afterRemove: 'resources/linux/packaging/after-remove.sh',
// Why: only an AppImage's files can vanish under a running daemon (see appimage-node-runtime.cjs).
fpm: appImageNodeRuntimeFpmArgs
},
beforeBuild: electronBuilderNativeRebuild,
// Why: must be true so that electron-builder rebuilds native modules
+61
View File
@@ -0,0 +1,61 @@
/**
* Linux builds place the pinned Node at resources/node-runtime/bin/node in afterPack, before the
* glibc-floor gate scans the tree. deb and rpm drop it again through fpm, so only the AppImage
* ships it: an AppImage's files vanish with its mount, an installed package's do not.
*/
const { chmodSync, copyFileSync, mkdirSync } = require('node:fs')
const { join, resolve } = require('node:path')
const { pathToFileURL } = require('node:url')
const {
APPIMAGE_NODE_RUNTIME_FPM_EXCLUDE,
APPIMAGE_NODE_RUNTIME_SEGMENTS,
appImageNodeRuntimeTarget
} = require('../../src/shared/appimage-node-runtime.ts')
const { runTerminalHostRoundTrip } = require('./verify-packaged-daemon-entry.cjs')
const appImageNodeRuntimeFpmArgs = ['--exclude', APPIMAGE_NODE_RUNTIME_FPM_EXCLUDE]
const PTY_ROUND_TRIP_TIMEOUT_MS = 20_000
async function ensurePinnedNode(target) {
const { ensurePinnedNodeExecutable } = await import(
pathToFileURL(resolve(__dirname, 'pinned-node-downloads.mjs')).href
)
return ensurePinnedNodeExecutable({ target })
}
/** `arch` is a Node arch name; the copy is hash-checked by the pinned download it comes from. */
async function placeAppImageNodeRuntime(resourcesDir, arch, ensureNode = ensurePinnedNode) {
const target = appImageNodeRuntimeTarget(arch)
if (!target) {
throw new Error(`[appimage-node-runtime] the Node pin has no glibc build for Linux ${arch}`)
}
const destination = join(resourcesDir, ...APPIMAGE_NODE_RUNTIME_SEGMENTS)
mkdirSync(join(destination, '..'), { recursive: true })
copyFileSync(await ensureNode(target), destination)
chmodSync(destination, 0o755)
return destination
}
/** The bundled Node must load the packaged node-pty and serve one PTY, as the daemon will. */
async function verifyAppImageNodeRuntimeServesPty(resourcesDir, runtimePath) {
const stderr = []
try {
await runTerminalHostRoundTrip({
command: [runtimePath],
entry: join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'),
deadline: Date.now() + PTY_ROUND_TRIP_TIMEOUT_MS,
stderr
})
} catch (error) {
throw new Error(
`[appimage-node-runtime] the bundled Node did not serve a PTY: ${error.message}\n${stderr.join('').slice(-2000)}`
)
}
console.log('[appimage-node-runtime] OK — the bundled Node served a PTY')
}
module.exports = {
appImageNodeRuntimeFpmArgs,
placeAppImageNodeRuntime,
verifyAppImageNodeRuntimeServesPty
}
@@ -0,0 +1,67 @@
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
appImageNodeRuntimeFpmArgs,
placeAppImageNodeRuntime
} = require('./appimage-node-runtime.cjs')
const releaseConfig = require('../electron-builder.config.cjs')
const prLinuxConfig = require('../electron-builder-pr-linux.config.cjs')
let root = ''
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-appimage-node-pack-'))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
/** Ruby's File.fnmatch without flags, which is how fpm applies --exclude: `*` crosses `/`. */
function fpmExcludes(pattern, stagedPath) {
const source = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*')
return new RegExp(`^${source}$`).test(stagedPath)
}
describe('the pinned Node in Linux packages', () => {
it('is placed under resources as an executable bin/node for the slice arch', async () => {
const pinned = join(root, 'pinned-node')
writeFileSync(pinned, 'node bytes')
const requested = []
const destination = await placeAppImageNodeRuntime(join(root, 'resources'), 'arm64', (t) => {
requested.push(t)
return pinned
})
expect(requested).toEqual(['linux-arm64-glibc'])
expect(destination).toBe(join(root, 'resources', 'node-runtime', 'bin', 'node'))
expect(readFileSync(destination, 'utf8')).toBe('node bytes')
expect(statSync(destination).mode & 0o777).toBe(0o755)
})
it('fails packaging for a slice the pin has no glibc build for', async () => {
await expect(
placeAppImageNodeRuntime(join(root, 'resources'), 'ia32', () => '')
).rejects.toThrow('no glibc build for Linux ia32')
})
it('ships only in the AppImage: deb and rpm exclude it, release and PR builds alike', () => {
for (const config of [releaseConfig, prLinuxConfig]) {
for (const target of ['deb', 'rpm']) {
const fpm = config[target].fpm.join('\0')
expect(fpm).toContain(appImageNodeRuntimeFpmArgs.join('\0'))
}
expect(config.appImage.fpm).toBeUndefined()
}
const [, pattern] = appImageNodeRuntimeFpmArgs
expect(fpmExcludes(pattern, 'opt/Orca/resources/node-runtime')).toBe(true)
expect(fpmExcludes(pattern, 'opt/Orca/resources/node_modules')).toBe(false)
expect(fpmExcludes(pattern, 'opt/Orca/resources/app.asar.unpacked/out/node-runtime')).toBe(
false
)
})
})
@@ -0,0 +1,42 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin'
import { findAppImageNodeRuntime } from './appimage-bundled-node-runtime'
let resources = ''
beforeEach(() => {
resources = mkdtempSync(join(tmpdir(), 'orca-appimage-node-'))
})
afterEach(() => {
rmSync(resources, { recursive: true, force: true })
})
function bundle(): string {
const path = join(resources, 'node-runtime', 'bin', 'node')
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, '')
return path
}
describe('findAppImageNodeRuntime', () => {
it('names the pinned glibc Node a Linux package carries for its CPU', () => {
const path = bundle()
expect(findAppImageNodeRuntime(resources, 'arm64', 'linux')).toEqual({
path,
target: 'linux-arm64-glibc',
executableSha256: NODE_RUNTIME_ASSETS['linux-arm64-glibc'].executableSha256
})
})
it('finds nothing in a package without one, or off Linux', () => {
expect(findAppImageNodeRuntime(resources, 'x64', 'linux')).toBeNull()
bundle()
expect(findAppImageNodeRuntime(resources, 'x64', 'darwin')).toBeNull()
expect(findAppImageNodeRuntime(resources, 'ia32', 'linux')).toBeNull()
expect(findAppImageNodeRuntime(undefined, 'x64', 'linux')).toBeNull()
})
})
@@ -0,0 +1,50 @@
/** The pinned Node inside a Linux AppImage's resources, used where Orca would otherwise download it. */
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { chmod, copyFile, mkdir, rename, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
APPIMAGE_NODE_RUNTIME_SEGMENTS,
appImageNodeRuntimeTarget
} from '../../shared/appimage-node-runtime'
import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin'
import { verifyFileSha256 } from './pinned-runtime-materializer'
export type AppImageNodeRuntime = {
path: string
target: ServerTarget
executableSha256: string
}
/** Null for every package that carries none: deb, rpm, macOS, Windows and dev builds. */
export function findAppImageNodeRuntime(
resourcesPath: string | undefined,
arch: string = process.arch,
platform: NodeJS.Platform = process.platform
): AppImageNodeRuntime | null {
const target = appImageNodeRuntimeTarget(arch)
if (platform !== 'linux' || !resourcesPath || !target) {
return null
}
const path = join(resourcesPath, ...APPIMAGE_NODE_RUNTIME_SEGMENTS)
return existsSync(path)
? { path, target, executableSha256: NODE_RUNTIME_ASSETS[target].executableSha256 }
: null
}
/** Copies the bundled Node to `destination`; only bytes that hash to the pin are published. */
export async function copyVerifiedAppImageNodeRuntime(
runtime: AppImageNodeRuntime,
destination: string
): Promise<void> {
await mkdir(dirname(destination), { recursive: true })
const staging = `${destination}.copy-${process.pid}-${randomUUID()}`
try {
await copyFile(runtime.path, staging)
await verifyFileSha256(staging, runtime.executableSha256, 'bundled Node')
await chmod(staging, 0o755)
await rename(staging, destination)
} finally {
await rm(staging, { force: true })
}
}
@@ -11,6 +11,7 @@ import {
} from '../../shared/orcad-local-serve-selection'
import { selectServeRuntime } from './orcad-local-serve-selection'
import { pruneDesktopOrcadArtifactCache } from './orcad-artifact-cache-retention'
import { findAppImageNodeRuntime } from '../node-runtime/appimage-bundled-node-runtime'
function flagValue(argv: readonly string[], flag: string): string | null {
const index = argv.indexOf(flag)
@@ -30,7 +31,8 @@ async function run(argv: readonly string[]): Promise<void> {
templateDirs: [
...(process.resourcesPath ? [join(process.resourcesPath, 'orcad-template')] : []),
join(appRoot, 'out', 'orcad-template')
]
],
bundledNodeRuntime: findAppImageNodeRuntime(process.resourcesPath)
})
// orcad serve never starts the desktop's startup pass, so the slot cache is bounded here too.
await pruneDesktopOrcadArtifactCache(
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,6 +22,7 @@ import {
import { hashProfileStatePayload } from '../persistence/profile-state/profile-state-document-validation'
import { importProfileStateJson } from '../persistence/profile-state/profile-state-documents'
import { selectServeRuntime, type ServeRuntimeSelectionInput } from './orcad-local-serve-selection'
import type { AppImageNodeRuntime } from '../node-runtime/appimage-bundled-node-runtime'
const TARGET = 'linux-x64-glibc'
const SSH_TARGET = { id: 'ssh-1', label: 'box', host: 'box.example', port: 22, username: 'me' }
@@ -202,6 +204,57 @@ describe('orca serve runtime selection', () => {
})
})
describe('an AppImage that carries the pinned Node', () => {
const offline = (): ServeRuntimeSelectionInput['materializeRuntime'] =>
vi.fn(async (): Promise<string> => {
throw new Error('fetch failed')
})
function bundledNode(bytes: string): AppImageNodeRuntime {
const path = join(root, 'resources', 'node-runtime', 'bin', 'node')
mkdirSync(join(path, '..'), { recursive: true })
writeFileSync(path, bytes)
return {
path,
target: TARGET,
executableSha256: createHash('sha256').update('#!/bin/sh\n# pinned\n').digest('hex')
}
}
it('prepares the slot from it on an offline first run', async () => {
const selection = await selectServeRuntime(
input({
materializeRuntime: offline(),
bundledNodeRuntime: bundledNode('#!/bin/sh\n# pinned\n')
})
)
const runtime = join(
root,
'orcad-artifacts',
TARGET,
'0.1.0+abc',
...orcadNodeRuntimeRelativePath(TARGET, SHA)
)
expect(selection).toMatchObject({ kind: 'orcad', runtime })
expect(readFileSync(runtime, 'utf8')).toBe('#!/bin/sh\n# pinned\n')
})
it('downloads instead when the bundled bytes are not the pin', async () => {
const options = input({ bundledNodeRuntime: bundledNode('#!/bin/sh\n# tampered\n') })
expect(await selectServeRuntime(options)).toMatchObject({ kind: 'orcad' })
expect(options.materializeRuntime).toHaveBeenCalledOnce()
})
it('downloads instead when the bundled Node is for another host', async () => {
const options = input({
hostTarget: () => 'linux-x64-musl',
bundledNodeRuntime: bundledNode('#!/bin/sh\n# pinned\n')
})
await selectServeRuntime(options)
expect(options.materializeRuntime).toHaveBeenCalledOnce()
})
})
it.each([
[
'an unknown runtime name',
+20 -3
View File
@@ -31,6 +31,10 @@ import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime'
import { detectNativeHostAbi, nativeSlotName } from './native-host-abi'
import { materializeOrcadArtifact } from './orcad-artifact-materializer'
import { materializeCachedNodeRuntime } from '../node-runtime/pinned-runtime-materializer'
import {
copyVerifiedAppImageNodeRuntime,
type AppImageNodeRuntime
} from '../node-runtime/appimage-bundled-node-runtime'
import { serveProfileHasSshTargets } from './serve-profile-ssh-targets'
import { errorMessage } from '../../shared/error-message'
@@ -44,6 +48,8 @@ export type ServeRuntimeSelectionInput = {
hostTarget?: () => string
materializeSlot?: typeof materializeOrcadArtifact
materializeRuntime?: typeof materializeCachedNodeRuntime
/** The pinned Node an AppImage carries; the slot uses it before trying a download. */
bundledNodeRuntime?: AppImageNodeRuntime | null
nativePreflight?: (runtime: string, entry: string) => Promise<string>
profileHasSshTargets?: (userDataPath: string) => boolean
}
@@ -87,7 +93,7 @@ export async function selectServeRuntime(
templateDir,
cacheRoot
})
await placeSlotRuntime(slotDir, target, cacheRoot, input.materializeRuntime)
await placeSlotRuntime(slotDir, target, cacheRoot, input)
} catch (error) {
return electron(`the orcad slot for ${target} could not be prepared: ${errorMessage(error)}`)
}
@@ -133,12 +139,15 @@ function profileSshReason(input: ServeRuntimeSelectionInput): string | null {
}
}
/** The slot references its runtime beside it; the cached pinned Node is linked into place. */
/** The slot references its runtime beside it: the AppImage's own copy, else the cached download. */
async function placeSlotRuntime(
slotDir: string,
target: ServerTarget,
cacheRoot: string,
materializeRuntime: typeof materializeCachedNodeRuntime = materializeCachedNodeRuntime
{
materializeRuntime = materializeCachedNodeRuntime,
bundledNodeRuntime
}: Pick<ServeRuntimeSelectionInput, 'materializeRuntime' | 'bundledNodeRuntime'>
): Promise<void> {
const destination = join(
slotDir,
@@ -147,6 +156,14 @@ async function placeSlotRuntime(
if (existsSync(destination)) {
return
}
if (bundledNodeRuntime?.target === target) {
try {
await copyVerifiedAppImageNodeRuntime(bundledNodeRuntime, destination)
return
} catch (error) {
console.error(`orca serve: not using the bundled Node (${errorMessage(error)})`)
}
}
const cached = await materializeRuntime(target, cacheRoot, { fetcher: fetch })
mkdirSync(dirname(destination), { recursive: true })
try {
+24
View File
@@ -0,0 +1,24 @@
/**
* The pinned Node a Linux AppImage carries, so what runs outside Electron needs no download.
* deb and rpm exclude it at packaging time: their installs never vanish under a running process.
*
* Keep this file erasable-only TypeScript with no imports — packaging scripts require it directly.
*/
/** Segments under the app's resources dir, laid out like the upstream archive (`bin/node`). */
export const APPIMAGE_NODE_RUNTIME_SEGMENTS = ['node-runtime', 'bin', 'node'] as const
/** fpm `--exclude` pattern, matched against each staged path (`*` crosses `/` there). */
export const APPIMAGE_NODE_RUNTIME_FPM_EXCLUDE = '*/resources/node-runtime'
/** AppImages are glibc builds, so only a glibc host of the same CPU can use the bundled Node. */
export function appImageNodeRuntimeTarget(
arch: string
): 'linux-x64-glibc' | 'linux-arm64-glibc' | null {
if (arch === 'x64') {
return 'linux-x64-glibc'
}
if (arch === 'arm64') {
return 'linux-arm64-glibc'
}
return null
}