mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(linux): require static AppImage runtimes (#17319)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+265
@@ -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[*]})."
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user