From a0bacaeedf9ba757ceaf2dc73d599f3a7d8eaa09 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:18:00 -0700 Subject: [PATCH] fix(linux): require static AppImage runtimes (#17319) --- config/docker/headless-pairing/Dockerfile | 1 - .../docker/headless-serve-shutdown/Dockerfile | 3 +- .../run-appimage-desktop-startup-case.sh | 265 ++++++++++++++++++ config/electron-builder.config.cjs | 7 + config/reliability-gates.jsonc | 33 ++- config/scripts/build-linux-local.mjs | 65 +++++ config/scripts/build-linux-local.test.mjs | 85 ++++++ .../scripts/electron-builder-config.test.mjs | 21 +- .../headless-serve-shutdown-workflow.test.mjs | 53 ++++ config/scripts/pr-code-change-scope.mjs | 2 + config/scripts/pr-code-change-scope.test.mjs | 3 + .../run-headless-serve-shutdown-docker.mjs | 50 +++- .../static-appimage-package-contract.cjs | 260 +++++++++++++++++ .../static-appimage-package-contract.test.mjs | 225 +++++++++++++++ package.json | 2 +- 15 files changed, 1063 insertions(+), 12 deletions(-) create mode 100755 config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh create mode 100644 config/scripts/build-linux-local.mjs create mode 100644 config/scripts/build-linux-local.test.mjs create mode 100644 config/scripts/static-appimage-package-contract.cjs create mode 100644 config/scripts/static-appimage-package-contract.test.mjs diff --git a/config/docker/headless-pairing/Dockerfile b/config/docker/headless-pairing/Dockerfile index 8feafcc6e82..03664f68b0d 100644 --- a/config/docker/headless-pairing/Dockerfile +++ b/config/docker/headless-pairing/Dockerfile @@ -28,7 +28,6 @@ RUN apt-get update \ util-linux \ xauth \ xvfb \ - zlib1g-dev \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash orca diff --git a/config/docker/headless-serve-shutdown/Dockerfile b/config/docker/headless-serve-shutdown/Dockerfile index 669bb02b00a..13b1ed2b69f 100644 --- a/config/docker/headless-serve-shutdown/Dockerfile +++ b/config/docker/headless-serve-shutdown/Dockerfile @@ -22,16 +22,15 @@ RUN apt-get update \ libxkbcommon0 \ libxrandr2 \ libxss1 \ - p7zip-full \ procps \ util-linux \ xauth \ xvfb \ - zlib1g-dev \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash orca COPY run-signal-case.sh /usr/local/bin/run-signal-case +COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case ENTRYPOINT ["/usr/local/bin/run-signal-case"] diff --git a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh new file mode 100755 index 00000000000..59a6bef0e5c --- /dev/null +++ b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +appimage=${1:-/input/orca.AppImage} +startup_timeout_seconds=90 +if [[ $# -gt 1 ]]; then + echo "usage: run-appimage-desktop-startup-case.sh [appimage]" >&2 + exit 64 +fi + +if ((EUID == 0)); then + if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then + echo 'FAIL: unable to create the AppImage startup state directory' >&2 + exit 1 + fi + if ! chown orca:orca "$state_dir"; then + echo "FAIL: unable to hand the AppImage startup state directory to orca: $state_dir" >&2 + rm -rf -- "$state_dir" || true + exit 1 + fi + exec runuser --user orca --preserve-environment -- env \ + ORCA_STARTUP_STATE_DIR="$state_dir" \ + ORCA_STARTUP_STATE_DIR_CLEANUP=1 \ + "$0" "$@" +fi + +remove_state_dir_on_exit=${ORCA_STARTUP_STATE_DIR_CLEANUP:-0} +if [[ -n "${ORCA_STARTUP_STATE_DIR:-}" ]]; then + state_dir=$ORCA_STARTUP_STATE_DIR +else + if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then + echo 'FAIL: unable to create the AppImage startup state directory' >&2 + exit 1 + fi + remove_state_dir_on_exit=1 +fi +stdout_log="$state_dir/stdout.log" +stderr_log="$state_dir/stderr.log" +launcher_pid= +launcher_start_ticks= +launcher_pgid= +launcher_status= +launcher_waited=false +tree_pids=() +declare -A tree_start_ticks=() + +read_start_ticks() { + local pid=$1 + [[ -r "/proc/$pid/stat" ]] || return 1 + awk '{print $22}' "/proc/$pid/stat" +} + +identity_alive() { + local pid=$1 + local expected_ticks=$2 + [[ -n "$expected_ticks" ]] || return 1 + [[ -r "/proc/$pid/stat" ]] || return 1 + [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "$expected_ticks" ]] || return 1 + local process_state + process_state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true) + [[ -n "$process_state" && "$process_state" != Z* ]] +} + +collect_process_tree() { + tree_pids=() + tree_start_ticks=() + [[ -n "$launcher_pid" ]] || return + [[ -n "$launcher_start_ticks" ]] || return + tree_pids+=("$launcher_pid") + tree_start_ticks["$launcher_pid"]="$launcher_start_ticks" + local -a frontier=("$launcher_pid") + while ((${#frontier[@]})); do + local parent=${frontier[0]} + frontier=("${frontier[@]:1}") + while read -r child; do + [[ -n "$child" ]] || continue + [[ -z "${tree_start_ticks[$child]+present}" ]] || continue + local child_ticks + child_ticks=$(read_start_ticks "$child" 2>/dev/null || true) + [[ -n "$child_ticks" ]] || continue + tree_pids+=("$child") + tree_start_ticks["$child"]="$child_ticks" + frontier+=("$child") + done < <(ps -eo pid=,ppid= | awk -v parent="$parent" '$2 == parent {print $1}') + done +} + +process_is_xvfb() { + local pid=$1 + local command_name + command_name=$(ps -o comm= -p "$pid" 2>/dev/null || true) + [[ "$command_name" == Xvfb ]] && return 0 + local command_line + command_line=$(ps -o args= -p "$pid" 2>/dev/null || true) + [[ "$command_line" =~ (^|[[:space:]/])Xvfb([[:space:]]|$) ]] +} + +signal_process_group() { + local signal=$1 + identity_alive "$launcher_pid" "$launcher_start_ticks" || return 0 + [[ "$launcher_pgid" =~ ^[0-9]+$ ]] || return 0 + [[ "$launcher_pgid" != "$(ps -o pgid= -p "$$" | tr -d ' ')" ]] || return 0 + kill -s "$signal" -- "-$launcher_pgid" 2>/dev/null || true +} + +signal_owned_processes() { + local signal=$1 + local index pid ticks + for ((index = ${#tree_pids[@]} - 1; index >= 0; index--)); do + pid=${tree_pids[index]} + ticks=${tree_start_ticks[$pid]-} + if identity_alive "$pid" "$ticks"; then + kill -s "$signal" "$pid" 2>/dev/null || true + fi + done +} + +wait_for_owned_exit() { + local timeout_seconds=$1 + local deadline=$((SECONDS + timeout_seconds)) + local pid ticks alive + while ((SECONDS < deadline)); do + alive=0 + for pid in "${tree_pids[@]}"; do + ticks=${tree_start_ticks[$pid]-} + if identity_alive "$pid" "$ticks"; then + alive=1 + break + fi + done + if ((alive == 0)); then + return 0 + fi + sleep 0.2 + done + return 1 +} + +dump_logs() { + echo "--- desktop startup stdout ---" >&2 + cat "$stdout_log" >&2 2>/dev/null || true + echo "--- desktop startup stderr ---" >&2 + cat "$stderr_log" >&2 2>/dev/null || true +} + +cleanup_state_dir() { + [[ "$remove_state_dir_on_exit" == 1 ]] || return 0 + [[ "$state_dir" =~ ^/tmp/orca-appimage-startup\.[^/]+$ ]] || return 0 + [[ -d "$state_dir" && ! -L "$state_dir" && -O "$state_dir" ]] || return 0 + rm -rf -- "$state_dir" +} + +capture_launcher_status() { + [[ "$launcher_waited" == false ]] || return 0 + [[ -n "$launcher_pid" ]] || return 1 + if wait "$launcher_pid"; then + launcher_status=0 + else + launcher_status=$? + fi + launcher_waited=true +} + +report_launcher_exit() { + local reason=$1 + local observed_status=unknown + local exit_status=1 + if capture_launcher_status; then + observed_status=$launcher_status + if ((launcher_status != 0)); then + exit_status=$launcher_status + fi + fi + echo "FAIL: desktop launcher exited before ${reason} (status=${observed_status})" >&2 + exit "$exit_status" +} + +cleanup() { + local status=$? + trap - EXIT + signal_process_group TERM || true + signal_owned_processes TERM || true + if ! wait_for_owned_exit 10; then + signal_process_group KILL || true + signal_owned_processes KILL || true + wait_for_owned_exit 5 || status=1 + fi + capture_launcher_status || true + if ((status != 0)); then + dump_logs + else + if ! cleanup_state_dir; then + status=1 + dump_logs + fi + fi + exit "$status" +} +trap cleanup EXIT + +mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime" +chmod 700 "$state_dir/runtime" +export HOME="$state_dir/home" +export XDG_CONFIG_HOME="$state_dir/config" +export XDG_CACHE_HOME="$state_dir/cache" +export XDG_RUNTIME_DIR="$state_dir/runtime" +export LIBGL_ALWAYS_SOFTWARE=1 +export ORCA_STARTUP_DIAGNOSTICS=1 +ulimit -c 0 + +[[ -r "$appimage" ]] || { echo "FAIL: AppImage is not readable: $appimage" >&2; exit 1; } +[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; } + +setsid --wait dbus-run-session -- xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox \ + >"$stdout_log" 2>"$stderr_log" & +launcher_pid=$! +launcher_start_ticks=$(read_start_ticks "$launcher_pid" 2>/dev/null || true) +launcher_pgid=$(ps -o pgid= -p "$launcher_pid" 2>/dev/null | tr -d ' ' || true) +if [[ -z "$launcher_start_ticks" ]]; then + report_launcher_exit 'its identity could be recorded' +fi + +marker_seen=false +deadline=$((SECONDS + startup_timeout_seconds)) +while ((SECONDS < deadline)); do + if grep -Eq '^\[startup\] updater-setup-done t=[0-9]+$' "$stderr_log"; then + marker_seen=true + break + fi + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'the updater-setup-done marker' + fi + sleep 0.2 +done +if [[ "$marker_seen" != true ]]; then + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'the updater-setup-done marker' + fi + echo "FAIL: desktop AppImage did not emit updater-setup-done within ${startup_timeout_seconds}s" >&2 + exit 1 +fi +if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + echo "FAIL: desktop launcher identity changed after startup marker" >&2 + exit 1 +fi + +collect_process_tree +xvfb_pids=() +for pid in "${tree_pids[@]}"; do + if process_is_xvfb "$pid"; then + xvfb_pids+=("$pid") + fi +done +if ((${#xvfb_pids[@]} == 0)); then + echo "FAIL: no launcher-owned Xvfb process was found after startup" >&2 + exit 1 +fi +for pid in "${xvfb_pids[@]}"; do + if ! identity_alive "$pid" "${tree_start_ticks[$pid]-}"; then + echo "FAIL: launcher-owned Xvfb identity changed before cleanup" >&2 + exit 1 + fi +done + +echo "Desktop AppImage startup validation passed (launcher=${launcher_pid}, xvfb=${xvfb_pids[*]})." diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index a61be0da887..e06db8e6854 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,7 @@ const { verifyPackagedNodePtyJobOwnership } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') +const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -110,6 +111,7 @@ module.exports = { appId, productName: 'Orca', protocols: [{ name: 'Orca', schemes: ['orca'] }], + toolsets: { appimage: '1.0.3' }, ...(devChannelBuildVersion ? { extraMetadata: { version: devChannelBuildVersion } } : localBuildVersion @@ -230,6 +232,11 @@ module.exports = { 'node_modules/zod/**', 'node_modules/yaml/**' ], + artifactBuildCompleted: ({ file, arch }) => { + if (file.endsWith('.AppImage')) { + verifyStaticAppImagePackage(file, arch) + } + }, afterPack: async (context) => { // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bdc15f66dba..4c14cd516a6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -16278,16 +16278,17 @@ "providers": ["local-daemon"], "coveredPlatforms": ["linux"], "coveredProviders": ["local-daemon"], - "coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.", + "coverageNotes": "An Ubuntu 26.04 amd64 container first launches the original AppImage through dbus-run-session and xvfb-run with startup diagnostics, then extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, and exercises terminal-style foreground-process-group SIGINT plus the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same startup and foreground-AppRun identity contracts.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/14109", "https://linear.app/stably/issue/STA-4051" ], "invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.", - "oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.", + "oracle": "First start the original, readable-and-executable AppImage once in a fresh restricted Ubuntu 26.04 container through dbus-run-session -- xvfb-run -a --appimage-extract-and-run with ORCA_STARTUP_DIAGNOSTICS=1, and require the exact updater-setup-done marker within 90 seconds while fencing the launcher and owned Xvfb by PID start ticks. For each signal, start a separate unprivileged container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot", "shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh", + "shellcheck config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh", "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage", "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64" ], @@ -16295,7 +16296,8 @@ "src/main/startup/ensure-virtual-display.test.ts", "config/scripts/headless-serve-shutdown-workflow.test.mjs", "config/scripts/run-headless-serve-shutdown-docker.mjs", - "config/docker/headless-serve-shutdown/run-signal-case.sh" + "config/docker/headless-serve-shutdown/run-signal-case.sh", + "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh" ], "assertionRefs": [ { @@ -16312,15 +16314,19 @@ "file": "config/scripts/headless-serve-shutdown-workflow.test.mjs", "assertions": [ "PR CI builds an x64 AppImage before invoking the packaged shutdown oracle", + "the original AppImage desktop startup oracle is wired before extraction and signal cases", + "the bound AppImage is readable and executable before desktop launch and extraction", "the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb" ] }, { "file": "config/scripts/run-headless-serve-shutdown-docker.mjs", "assertions": [ + "the original AppImage startup runs through dbus-run-session and xvfb-run with a bounded diagnostics marker", "SIGINT and SIGTERM run in separate disposable containers", "both signal failures are reported before the oracle exits", "the exact AppImage SHA-256, entrypoint, and signal target are published", + "the read-only AppImage bind is checked for read and execute permissions before extraction", "the launcher exec overlay isolates the related STA-4017 signal boundary" ] }, @@ -16331,6 +16337,14 @@ "SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy", "target and descendant identities are fenced by PID start ticks before signaling and residue checks" ] + }, + { + "file": "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh", + "assertions": [ + "the original AppImage emits the exact updater-setup-done startup marker within 90 seconds", + "launcher and owned Xvfb identities are fenced by PID start ticks", + "cleanup sends bounded TERM then KILL signals and preserves failure logs" + ] } ], "evidenceRuns": [ @@ -16351,11 +16365,20 @@ "result": "passed", "durationSeconds": 48, "summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity." + }, + { + "date": "2026-08-31", + "runner": "ci", + "platform": "linux", + "command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64", + "result": "passed", + "durationSeconds": 1336, + "summary": "Native-amd64 PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 built AppImage SHA-256 999d43bfe123e87a77fe917a5f46be1efd5c45a5205f99f02998a75136d8a793 and ran the restricted original-AppImage startup oracle before each of the three signal matrices. Each startup reached the exact updater-setup-done marker with stable launcher/Xvfb PID-start-tick identities and bounded TERM/KILL cleanup; SIGINT and SIGTERM then returned wait status 0 with no fatal evidence, listener, descendant, Xvfb, or canary residue. This is CI evidence only; no fresh local Docker oracle is claimed." } ], "runtimeBudget": { - "p95Seconds": 240, - "scope": "two fresh Ubuntu 26.04 containers, one per foreground signal" + "p95Seconds": 1800, + "scope": "three AppImage startup/extraction matrices, each with fresh Ubuntu 26.04 INT and TERM containers" }, "flakeHistory": { "status": "not-started", diff --git a/config/scripts/build-linux-local.mjs b/config/scripts/build-linux-local.mjs new file mode 100644 index 00000000000..2328f00bede --- /dev/null +++ b/config/scripts/build-linux-local.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const SUPPORTED_ARCHES = new Set(['x64', 'arm64']) + +/** Select the local Linux package architecture without relying on builder defaults. */ +export function resolveLinuxBuildArch({ + platform = process.platform, + hostArch = process.arch, + requestedArch = process.env.ORCA_LINUX_BUILD_ARCH +} = {}) { + const arch = requestedArch ?? (platform === 'linux' ? hostArch : 'x64') + if (!SUPPORTED_ARCHES.has(arch)) { + throw new Error( + `Unsupported Linux build architecture: ${arch}. Use ORCA_LINUX_BUILD_ARCH=x64|arm64.` + ) + } + return arch +} + +export function buildLinuxElectronBuilderArgs(arch, extraArgs = []) { + if (!SUPPORTED_ARCHES.has(arch)) { + throw new Error(`Unsupported Linux build architecture: ${arch}`) + } + return [ + 'exec', + 'electron-builder', + '--config', + 'config/electron-builder.config.cjs', + '--linux', + 'AppImage', + 'deb', + 'rpm', + `--${arch}`, + ...extraArgs + ] +} + +export function runLocalLinuxBuild({ + arch = resolveLinuxBuildArch(), + extraArgs = [], + environment = process.env, + execFile = execFileSync, + platform = process.platform, + cwd = resolve(import.meta.dirname, '../..') +} = {}) { + const env = { ...environment } + if (arch === 'arm64') { + env.ORCA_LINUX_ARM64_RELEASE = '1' + } else { + delete env.ORCA_LINUX_ARM64_RELEASE + } + const pnpm = platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + execFile(pnpm, buildLinuxElectronBuilderArgs(arch, extraArgs), { + cwd, + env, + stdio: 'inherit' + }) +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + runLocalLinuxBuild({ extraArgs: process.argv.slice(2) }) +} diff --git a/config/scripts/build-linux-local.test.mjs b/config/scripts/build-linux-local.test.mjs new file mode 100644 index 00000000000..684c83f3265 --- /dev/null +++ b/config/scripts/build-linux-local.test.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + buildLinuxElectronBuilderArgs, + resolveLinuxBuildArch, + runLocalLinuxBuild +} from './build-linux-local.mjs' + +describe('local Linux build target', () => { + it('is the package script used by the local Linux build', () => { + const packageJson = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8') + ) + expect(packageJson.scripts['build:linux']).toContain( + 'node config/scripts/build-linux-local.mjs' + ) + }) + + it('follows a native Linux host architecture', () => { + expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'arm64' })).toBe('arm64') + expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'x64' })).toBe('x64') + }) + + it('defaults cross-platform Linux builds to x64 and allows an explicit override', () => { + expect(resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64' })).toBe('x64') + expect( + resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64', requestedArch: 'arm64' }) + ).toBe('arm64') + }) + + it('rejects unsupported architectures', () => { + expect(() => resolveLinuxBuildArch({ platform: 'linux', hostArch: 'ia32' })).toThrow( + 'Unsupported Linux build architecture' + ) + expect(() => buildLinuxElectronBuilderArgs('ia32')).toThrow( + 'Unsupported Linux build architecture' + ) + }) + + it('passes an explicit target and matching artifact-name environment', () => { + const execFile = vi.fn() + runLocalLinuxBuild({ + arch: 'arm64', + environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: undefined }, + execFile, + platform: 'linux', + cwd: '/workspace' + }) + expect(execFile).toHaveBeenCalledWith( + 'pnpm', + buildLinuxElectronBuilderArgs('arm64'), + expect.objectContaining({ + cwd: '/workspace', + env: expect.objectContaining({ ORCA_LINUX_ARM64_RELEASE: '1' }), + stdio: 'inherit' + }) + ) + + expect(buildLinuxElectronBuilderArgs('x64')).toEqual( + expect.arrayContaining(['--linux', 'AppImage', 'deb', 'rpm', '--x64']) + ) + + runLocalLinuxBuild({ + arch: 'x64', + environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: '1' }, + execFile, + platform: 'linux', + cwd: '/workspace' + }) + expect(execFile).toHaveBeenLastCalledWith( + 'pnpm', + buildLinuxElectronBuilderArgs('x64'), + expect.objectContaining({ + env: expect.not.objectContaining({ ORCA_LINUX_ARM64_RELEASE: expect.anything() }) + }) + ) + }) + + it('uses the Windows pnpm command name when cross-host packaging', () => { + const execFile = vi.fn() + runLocalLinuxBuild({ arch: 'x64', execFile, platform: 'win32', cwd: 'C:\\workspace' }) + expect(execFile.mock.calls[0]?.[0]).toBe('pnpm.cmd') + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index d7f207eb2b7..3bd14a3b095 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,5 +1,6 @@ -import { readFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -271,6 +272,7 @@ describe('electron-builder config', () => { it('uses the release artifact set as local Linux targets without changing existing names', () => { expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm']) + expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' }) expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}') expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}') expect(electronBuilderConfig.rpm).toMatchObject({ @@ -279,6 +281,23 @@ describe('electron-builder config', () => { }) }) + it('validates each AppImage before electron-builder publishes it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-')) + try { + const appImage = join(root, 'orca-linux.AppImage') + await writeFile(appImage, 'not an ELF') + await chmod(appImage, 0o755) + + expect(() => + electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 }) + ).toThrow(/ELF header is outside/) + expect(() => + electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') }) + ).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) it('uses a distinct AppImage name for Linux arm64 release uploads', () => { const configPath = require.resolve('../electron-builder.config.cjs') const original = process.env.ORCA_LINUX_ARM64_RELEASE diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index a693da31ce0..4f484891253 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -6,6 +6,15 @@ import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8') const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8') +const shutdownDockerRunner = readFileSync( + 'config/scripts/run-headless-serve-shutdown-docker.mjs', + 'utf8' +) +const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8') +const desktopStartupOracle = readFileSync( + 'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh', + 'utf8' +) function readSystemdUnitBlocks(doc, unitName) { const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -100,6 +109,50 @@ describe('headless serve shutdown PR gate', () => { expect(signalCase).toContain('outside the entrypoint process tree') }) + it('runs the original AppImage desktop startup oracle before extraction and signals', () => { + expect(shutdownDockerfile).toContain( + 'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case' + ) + const startupCall = shutdownDockerRunner.indexOf( + 'runDesktopStartupOracle({ image, appImage, platform })' + ) + const extractionCall = shutdownDockerRunner.indexOf( + "'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract" + ) + const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])") + expect(startupCall).toBeGreaterThan(-1) + expect(extractionCall).toBeGreaterThan(startupCall) + expect(signalLoop).toBeGreaterThan(startupCall) + expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'") + }) + + it('preserves startup logs when the launcher exits before its marker', () => { + expect(desktopStartupOracle).toContain('signal_process_group TERM || true') + expect(desktopStartupOracle).toContain('signal_process_group KILL || true') + expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true') + expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true') + expect(desktopStartupOracle).toContain( + 'FAIL: desktop launcher exited before ${reason} (status=${observed_status})' + ) + expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1') + expect(desktopStartupOracle).toContain( + '[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0' + ) + }) + + it('requires the bound AppImage to be executable before launch and extraction', () => { + expect(desktopStartupOracle).toContain( + '[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }' + ) + expect(shutdownDockerRunner).toContain( + '\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\'' + ) + }) + + it('gives the original AppImage enough bounded extraction space', () => { + expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'") + }) + it('keeps owned Xvfb alive during the documented systemd graceful stop', () => { const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service') const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit)) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 6b779fc7b1c..67d4f565afa 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -182,8 +182,10 @@ const SHARED_PACKAGE_PREFIXES = [ const LINUX_PACKAGE_PREFIXES = [ ...SHARED_PACKAGE_PREFIXES, 'config/docker/cli-launch-contract/', + 'config/docker/headless-pairing/', 'config/docker/headless-serve-shutdown/', 'config/scripts/run-linux-cli-launch-contract', + 'config/scripts/run-headless-linux-pairing-docker', 'config/scripts/static-appimage-package-contract', 'native/computer-use-linux/', 'resources/linux/', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index ba11faf8192..9a1c9e649b6 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -185,8 +185,11 @@ describe('per-job path classification', () => { for (const file of [ 'config/docker/cli-launch-contract/Dockerfile', 'config/docker/cli-launch-contract/run-cli-case.sh', + 'config/docker/headless-pairing/Dockerfile', + 'config/docker/headless-pairing/run-appimage-case.sh', 'config/docker/headless-serve-shutdown/Dockerfile', 'config/scripts/run-linux-cli-launch-contract-docker.mjs', + 'config/scripts/run-headless-linux-pairing-docker.mjs', 'config/scripts/static-appimage-package-contract.cjs' ]) { expectClassification([file], { package: true }) diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs index 8967bb36841..184713c41a0 100755 --- a/config/scripts/run-headless-serve-shutdown-docker.mjs +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -54,11 +54,19 @@ try { shutdownDockerDirectory ]) docker(['volume', 'create', artifactVolume]) + runDesktopStartupOracle({ image, appImage, platform }) docker([ 'run', '--rm', '--platform', platform, + '--network', + 'none', + '--read-only', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', '--entrypoint', 'bash', '-v', @@ -68,11 +76,17 @@ try { image, '-lc', [ - '7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null', + 'trap \'status=$?; if [ "$status" -ne 0 ]; then cat /artifacts/appimage-help.log /artifacts/appimage-extract.log 2>/dev/null || true; fi; exit "$status"\' EXIT', + 'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }', + 'timeout --kill-after=5s 15s /input/orca.AppImage --appimage-help > /artifacts/appimage-help.log 2>&1', + 'cd /artifacts', + 'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract > /artifacts/appimage-extract.log 2>&1', + 'mv squashfs-root root', launcherExecOverlay ? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide" : ':', - 'chmod -R a+rX /artifacts/root' + 'chmod -R a+rX /artifacts/root', + 'rm /artifacts/appimage-help.log /artifacts/appimage-extract.log' ].join(' && ') ]) @@ -131,6 +145,38 @@ try { docker(['image', 'rm', image], { allowFailure: true }) } +function runDesktopStartupOracle({ image, appImage, platform }) { + console.log('Running original AppImage desktop startup oracle...') + docker([ + 'run', + '--rm', + '--init', + '--platform', + platform, + '--network', + 'none', + '--read-only', + '--tmpfs', + '/tmp:rw,nosuid,nodev,exec,size=1g', + '--shm-size', + '256m', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + 'orca', + '--entrypoint', + '/usr/local/bin/run-appimage-desktop-startup-case', + '-e', + 'ORCA_STARTUP_DIAGNOSTICS=1', + '-v', + `${appImage}:/input/orca.AppImage:ro`, + image, + '/input/orca.AppImage' + ]) +} + function valueAfter(flag) { const index = args.indexOf(flag) return index === -1 ? null : (args[index + 1] ?? null) diff --git a/config/scripts/static-appimage-package-contract.cjs b/config/scripts/static-appimage-package-contract.cjs new file mode 100644 index 00000000000..8a11cecf880 --- /dev/null +++ b/config/scripts/static-appimage-package-contract.cjs @@ -0,0 +1,260 @@ +const { closeSync, fstatSync, openSync, readSync } = require('node:fs') +const { basename } = require('node:path') + +const EXPECTED_ARCHITECTURE_BY_FILENAME = new Map([ + ['orca-linux.AppImage', 'x64'], + ['orca-linux-arm64.AppImage', 'arm64'] +]) +const APPIMAGE_MAGIC = Buffer.from([0x41, 0x49, 0x02]) +const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime') +const TARGET_ARCHITECTURE_BY_ENUM = new Map([ + [1, 'x64'], + [3, 'arm64'] +]) +const RUNTIME_ARCHITECTURE_BY_MACHINE = new Map([ + [0x3e, 'x64'], + [0xb7, 'arm64'] +]) +const ELF_HEADER_BYTES = 64 +const PROGRAM_HEADER_BYTES = 56 +const DYNAMIC_ENTRY_BYTES = 16 +const MAX_PROGRAM_HEADERS = 128 +const MAX_LOAD_BYTES = 16 * 1024 * 1024 +const MAX_DYNAMIC_BYTES = 1024 * 1024 + +function verifyStaticAppImagePackage(filePath, targetArch) { + const filename = basename(filePath) + const filenameArchitecture = EXPECTED_ARCHITECTURE_BY_FILENAME.get(filename) + if (!filenameArchitecture) { + invalid( + filename, + `unsupported artifact name; expected ${[...EXPECTED_ARCHITECTURE_BY_FILENAME.keys()].join(' or ')}` + ) + } + const targetArchitecture = normalizeTargetArchitecture(targetArch, filename) + if (filenameArchitecture !== targetArchitecture) { + invalid( + filename, + `artifact filename targets ${filenameArchitecture}, but electron-builder target is ${targetArchitecture}` + ) + } + + const descriptor = openSync(filePath, 'r') + try { + const stats = fstatSync(descriptor, { bigint: true }) + if (process.platform !== 'win32' && (stats.mode & 0o111n) === 0n) { + invalid(filename, 'artifact is not executable') + } + const fileSize = stats.size + const header = readRange( + descriptor, + 0n, + BigInt(ELF_HEADER_BYTES), + fileSize, + filename, + 'ELF header' + ) + const { entry, machine } = verifyElfHeader(header, filename) + const runtimeArchitecture = RUNTIME_ARCHITECTURE_BY_MACHINE.get(machine) + if (runtimeArchitecture !== targetArchitecture) { + invalid( + filename, + `runtime architecture ${runtimeArchitecture ?? `machine 0x${machine.toString(16)}`} does not match electron-builder target ${targetArchitecture}` + ) + } + + const programHeaderOffset = header.readBigUInt64LE(32) + const programHeaderSize = header.readUInt16LE(54) + const programHeaderCount = header.readUInt16LE(56) + if (programHeaderSize !== PROGRAM_HEADER_BYTES) { + invalid(filename, `unexpected ELF program-header size ${programHeaderSize}`) + } + if (programHeaderCount === 0 || programHeaderCount > MAX_PROGRAM_HEADERS) { + invalid(filename, `invalid ELF program-header count ${programHeaderCount}`) + } + + const tableSize = BigInt(programHeaderSize * programHeaderCount) + const table = readRange( + descriptor, + programHeaderOffset, + tableSize, + fileSize, + filename, + 'ELF program-header table' + ) + const segments = parseProgramHeaders(table, programHeaderSize) + verifySegments(descriptor, segments, fileSize, filename, entry) + } finally { + closeSync(descriptor) + } +} + +function verifyElfHeader(header, filename) { + if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + invalid(filename, 'missing ELF magic') + } + if (header[4] !== 2 || header[5] !== 1 || header[6] !== 1) { + invalid(filename, 'runtime must be ELF64 little-endian version 1') + } + if (!header.subarray(8, 11).equals(APPIMAGE_MAGIC)) { + invalid(filename, 'missing type-2 AppImage marker') + } + if (header.readUInt16LE(16) !== 3) { + invalid(filename, 'runtime must be an ET_DYN static PIE') + } + const machine = header.readUInt16LE(18) + if (!RUNTIME_ARCHITECTURE_BY_MACHINE.has(machine)) { + invalid(filename, `unsupported ELF machine 0x${machine.toString(16)}`) + } + if (header.readUInt32LE(20) !== 1) { + invalid(filename, 'runtime has an unsupported ELF version') + } + if (header.readUInt16LE(52) !== ELF_HEADER_BYTES) { + invalid(filename, `unexpected ELF header size ${header.readUInt16LE(52)}`) + } + return { entry: header.readBigUInt64LE(24), machine } +} + +function parseProgramHeaders(table, entrySize) { + const segments = [] + for (let offset = 0; offset < table.length; offset += entrySize) { + segments.push({ + type: table.readUInt32LE(offset), + flags: table.readUInt32LE(offset + 4), + offset: table.readBigUInt64LE(offset + 8), + virtualAddress: table.readBigUInt64LE(offset + 16), + fileSize: table.readBigUInt64LE(offset + 32), + memorySize: table.readBigUInt64LE(offset + 40) + }) + } + return segments +} + +function verifySegments(descriptor, segments, fileSize, filename, entry) { + if (segments.some((segment) => segment.type === 3)) { + invalid(filename, 'runtime contains PT_INTERP') + } + + const loadSegments = segments.filter((segment) => segment.type === 1) + const totalLoadBytes = loadSegments.reduce((total, segment) => total + segment.fileSize, 0n) + if (loadSegments.length === 0 || totalLoadBytes > BigInt(MAX_LOAD_BYTES)) { + invalid(filename, `invalid or oversized PT_LOAD data (${totalLoadBytes} bytes)`) + } + if ( + !loadSegments.some( + (segment) => + segment.flags & 1 && + entry >= segment.virtualAddress && + entry - segment.virtualAddress < segment.memorySize + ) + ) { + invalid(filename, 'ELF entry point is outside an executable PT_LOAD segment') + } + let identifiesStaticRuntime = false + for (const segment of loadSegments) { + verifyFileBackedSegment(segment, fileSize, filename, 'PT_LOAD') + const data = readRange( + descriptor, + segment.offset, + segment.fileSize, + fileSize, + filename, + 'PT_LOAD data' + ) + identifiesStaticRuntime ||= data.includes(RUNTIME_SOURCE) + } + if (!identifiesStaticRuntime) { + invalid(filename, `runtime does not identify ${RUNTIME_SOURCE.toString()}`) + } + + for (const segment of segments.filter((entry) => entry.type === 2)) { + verifyDynamicSegment(descriptor, segment, fileSize, filename) + } +} + +function normalizeTargetArchitecture(targetArch, filename) { + const architecture = + typeof targetArch === 'number' ? TARGET_ARCHITECTURE_BY_ENUM.get(targetArch) : targetArch + if (architecture !== 'x64' && architecture !== 'arm64') { + invalid(filename, `unsupported electron-builder target architecture ${String(targetArch)}`) + } + return architecture +} + +function verifyFileBackedSegment(segment, fileSize, filename, label) { + if (segment.memorySize < segment.fileSize) { + invalid(filename, `${label} memory size is smaller than its file size`) + } + verifyRange(segment.offset, segment.fileSize, fileSize, filename, label) +} + +function verifyDynamicSegment(descriptor, segment, fileSize, filename) { + verifyFileBackedSegment(segment, fileSize, filename, 'PT_DYNAMIC') + if ( + segment.fileSize === 0n || + segment.fileSize > BigInt(MAX_DYNAMIC_BYTES) || + segment.fileSize % BigInt(DYNAMIC_ENTRY_BYTES) !== 0n + ) { + invalid(filename, `invalid PT_DYNAMIC size ${segment.fileSize}`) + } + const dynamic = readRange( + descriptor, + segment.offset, + segment.fileSize, + fileSize, + filename, + 'PT_DYNAMIC data' + ) + let terminated = false + for (let offset = 0; offset < dynamic.length; offset += DYNAMIC_ENTRY_BYTES) { + const tag = dynamic.readBigInt64LE(offset) + if (tag === 0n) { + terminated = true + break + } + if (tag === 1n) { + invalid(filename, 'runtime contains a DT_NEEDED dependency') + } + } + if (!terminated) { + invalid(filename, 'PT_DYNAMIC is missing DT_NULL') + } +} + +function readRange(descriptor, offset, size, fileSize, filename, label) { + verifyRange(offset, size, fileSize, filename, label) + const buffer = Buffer.alloc(Number(size)) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const count = readSync( + descriptor, + buffer, + bytesRead, + buffer.length - bytesRead, + Number(offset) + bytesRead + ) + if (count === 0) { + throw new Error(`Unable to read complete ${label}`) + } + bytesRead += count + } + return buffer +} + +function verifyRange(offset, size, fileSize, filename, label) { + const maxSafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + if ( + offset > fileSize || + size > fileSize - offset || + offset > maxSafeOffset || + size > maxSafeOffset - offset + ) { + invalid(filename, `${label} is outside the artifact`) + } +} + +function invalid(filename, reason) { + throw new Error(`Invalid static AppImage ${filename}: ${reason}`) +} + +module.exports = { verifyStaticAppImagePackage } diff --git a/config/scripts/static-appimage-package-contract.test.mjs b/config/scripts/static-appimage-package-contract.test.mjs new file mode 100644 index 00000000000..2addc675482 --- /dev/null +++ b/config/scripts/static-appimage-package-contract.test.mjs @@ -0,0 +1,225 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { verifyStaticAppImagePackage } = require('./static-appimage-package-contract.cjs') + +const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime') +const LOAD_HEADER = 64 +const DYNAMIC_HEADER = 120 +const DYNAMIC_OFFSET = 320 +const FIXTURE_BYTES = 384 + +describe('static AppImage package contract', () => { + it.each([ + ['orca-linux.AppImage', 0x3e, 1], + ['orca-linux-arm64.AppImage', 0xb7, 'arm64'] + ])('accepts a dependency-free type-2 %s runtime', async (filename, machine, targetArch) => { + await withFixture(filename, createRuntime({ machine }), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).not.toThrow() + }) + }) + + it.each([ + ['generic filename for an arm64 runtime and target', 'orca-linux.AppImage', 0xb7, 3], + ['arm64 filename for an x64 runtime and target', 'orca-linux-arm64.AppImage', 0x3e, 1], + ['generic x64 runtime for an arm64 target', 'orca-linux.AppImage', 0x3e, 3], + ['generic arm64 runtime for an x64 target', 'orca-linux.AppImage', 0xb7, 1], + ['arm64 artifact filename for an x64 target', 'orca-linux-arm64.AppImage', 0xb7, 1], + ['x64 runtime under an arm64 artifact filename', 'orca-linux-arm64.AppImage', 0x3e, 3] + ])('rejects %s', async (_label, filename, machine, targetArch) => { + await withFixture(filename, createRuntime({ machine }), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/architecture|target/) + }) + }) + + it.each([undefined, 0, 'ia32'])( + 'rejects unsupported target architecture %s', + async (targetArch) => { + await withFixture('orca-linux.AppImage', createRuntime(), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/target architecture/) + }) + } + ) + + it('accepts PT_DYNAMIC relocation metadata without dependencies', async () => { + const runtime = createRuntime() + runtime.writeBigInt64LE(7n, DYNAMIC_OFFSET) + await withFixture('orca-linux.AppImage', runtime, (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow() + }) + }) + + it('does not scan the appended AppImage payload as outer ELF data', async () => { + const payload = Buffer.concat([RUNTIME_SOURCE, Buffer.alloc(16, 1)]) + await withFixture('orca-linux.AppImage', Buffer.concat([createRuntime(), payload]), (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow() + }) + + const unidentifiedRuntime = createRuntime() + unidentifiedRuntime.fill(0, 192, 192 + RUNTIME_SOURCE.length) + await withFixture( + 'orca-linux.AppImage', + Buffer.concat([unidentifiedRuntime, payload]), + (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/does not identify/) + } + ) + }) + + it('rejects artifact names outside the release contract before reading them', () => { + expect(() => verifyStaticAppImagePackage('/missing/orca-preview.AppImage')).toThrow( + 'unsupported artifact name' + ) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a readable but non-executable AppImage', + async () => { + await withFixture( + 'orca-linux.AppImage', + createRuntime(), + (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/not executable/) + }, + { mode: 0o644 } + ) + } + ) + + it.each([ + [ + 'non-ELF64 runtimes', + (runtime) => { + runtime[4] = 1 + }, + /ELF64 little-endian/ + ], + [ + 'unsupported ELF versions', + (runtime) => runtime.writeUInt32LE(2, 20), + /unsupported ELF version/ + ], + [ + 'non-type-2 AppImages', + (runtime) => { + runtime[10] = 1 + }, + /type-2 AppImage marker/ + ], + ['non-PIE runtimes', (runtime) => runtime.writeUInt16LE(2, 16), /ET_DYN static PIE/], + [ + 'unsupported architectures', + (runtime) => runtime.writeUInt16LE(3, 18), + /unsupported ELF machine/ + ], + ['dynamic loaders', (runtime) => runtime.writeUInt32LE(3, DYNAMIC_HEADER), /PT_INTERP/], + [ + 'shared-library dependencies', + (runtime) => runtime.writeBigInt64LE(1n, DYNAMIC_OFFSET), + /DT_NEEDED/ + ], + [ + 'unidentified runtimes', + (runtime) => runtime.fill(0, 192, 192 + RUNTIME_SOURCE.length), + /does not identify/ + ], + [ + 'out-of-bounds load segments', + (runtime) => { + runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 32) + runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 40) + }, + /outside the artifact/ + ], + [ + 'oversized load claims', + (runtime) => { + runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 32) + runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 40) + }, + /oversized PT_LOAD/ + ], + [ + 'non-executable entry segments', + (runtime) => runtime.writeUInt32LE(4, LOAD_HEADER + 4), + /executable PT_LOAD/ + ], + [ + 'entry points outside load segments', + (runtime) => runtime.writeBigUInt64LE(4096n, 24), + /entry point/ + ] + ])('rejects %s', async (_label, mutate, expected) => { + const runtime = createRuntime() + mutate(runtime) + await withFixture('orca-linux.AppImage', runtime, (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(expected) + }) + }) +}) + +function createRuntime({ machine = 0x3e } = {}) { + const runtime = Buffer.alloc(FIXTURE_BYTES) + Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(runtime) + Buffer.from([0x41, 0x49, 0x02]).copy(runtime, 8) + runtime.writeUInt16LE(3, 16) + runtime.writeUInt16LE(machine, 18) + runtime.writeUInt32LE(1, 20) + runtime.writeBigUInt64LE(0n, 24) + runtime.writeBigUInt64LE(64n, 32) + runtime.writeUInt16LE(64, 52) + runtime.writeUInt16LE(56, 54) + runtime.writeUInt16LE(2, 56) + + writeProgramHeader(runtime, LOAD_HEADER, { + type: 1, + flags: 5, + offset: 0, + virtualAddress: 0, + size: FIXTURE_BYTES, + memorySize: FIXTURE_BYTES, + alignment: 4096 + }) + writeProgramHeader(runtime, DYNAMIC_HEADER, { + type: 2, + flags: 4, + offset: DYNAMIC_OFFSET, + virtualAddress: DYNAMIC_OFFSET, + size: 32, + memorySize: 32, + alignment: 8 + }) + RUNTIME_SOURCE.copy(runtime, 192) + return runtime +} + +function writeProgramHeader( + runtime, + headerOffset, + { type, flags, offset, virtualAddress, size, memorySize = size, alignment } +) { + runtime.writeUInt32LE(type, headerOffset) + runtime.writeUInt32LE(flags, headerOffset + 4) + runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 8) + runtime.writeBigUInt64LE(BigInt(virtualAddress), headerOffset + 16) + runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 24) + runtime.writeBigUInt64LE(BigInt(size), headerOffset + 32) + runtime.writeBigUInt64LE(BigInt(memorySize), headerOffset + 40) + runtime.writeBigUInt64LE(BigInt(alignment), headerOffset + 48) +} + +async function withFixture(filename, contents, check, { mode = 0o755 } = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-static-appimage-contract-')) + try { + const path = join(root, filename) + await writeFile(path, contents) + await chmod(path, mode) + await check(path) + } finally { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/package.json b/package.json index 2d803692357..2545e2128da 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "build:icons": "bash resources/icon-source/generate.sh", "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", - "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm", + "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && node config/scripts/build-linux-local.mjs", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", "test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",