diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bbd6288c33b..0997222a54a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index d67399d7096..108af35554d 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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 diff --git a/config/scripts/appimage-node-runtime.cjs b/config/scripts/appimage-node-runtime.cjs new file mode 100644 index 00000000000..34d393e1521 --- /dev/null +++ b/config/scripts/appimage-node-runtime.cjs @@ -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 +} diff --git a/config/scripts/appimage-node-runtime.test.mjs b/config/scripts/appimage-node-runtime.test.mjs new file mode 100644 index 00000000000..4161d9e7761 --- /dev/null +++ b/config/scripts/appimage-node-runtime.test.mjs @@ -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 + ) + }) +}) diff --git a/src/main/node-runtime/appimage-bundled-node-runtime.test.ts b/src/main/node-runtime/appimage-bundled-node-runtime.test.ts new file mode 100644 index 00000000000..6c8cbafb68b --- /dev/null +++ b/src/main/node-runtime/appimage-bundled-node-runtime.test.ts @@ -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() + }) +}) diff --git a/src/main/node-runtime/appimage-bundled-node-runtime.ts b/src/main/node-runtime/appimage-bundled-node-runtime.ts new file mode 100644 index 00000000000..2cb7ab1b204 --- /dev/null +++ b/src/main/node-runtime/appimage-bundled-node-runtime.ts @@ -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 { + 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 }) + } +} diff --git a/src/main/orcad/orcad-local-serve-selection-entry.ts b/src/main/orcad/orcad-local-serve-selection-entry.ts index 016ac730159..7546dbe1fa1 100644 --- a/src/main/orcad/orcad-local-serve-selection-entry.ts +++ b/src/main/orcad/orcad-local-serve-selection-entry.ts @@ -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 { 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( diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts index f6df27980c4..e9a775beff8 100644 --- a/src/main/orcad/orcad-local-serve-selection.test.ts +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -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 => { + 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', diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index 8b18969f275..75cc3c5a42c 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -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 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 ): Promise { 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 { diff --git a/src/shared/appimage-node-runtime.ts b/src/shared/appimage-node-runtime.ts new file mode 100644 index 00000000000..96d7889f70f --- /dev/null +++ b/src/shared/appimage-node-runtime.ts @@ -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 +}