diff --git a/.gitattributes b/.gitattributes
index f4676d210bb..97eeed155a2 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,11 +1,7 @@
-/config/scripts/create-draft-release.mjs text eol=lf
-/config/scripts/orca-dev.mjs text eol=lf
-/config/scripts/latest-stable-release.mjs text eol=lf
-/config/scripts/publish-complete-draft-releases.mjs text eol=lf
-/config/scripts/release-rc-history.mjs text eol=lf
-/config/scripts/run-internal-dev-setup.mjs text eol=lf
-/config/scripts/verify-cli-bin.mjs text eol=lf
-/config/scripts/verify-release-required-assets.mjs text eol=lf
+# A shebang plus CRLF makes vite's SSR transform emit a literal `#!` mid-module,
+# so any suite importing the script dies at load with a SyntaxError. Pin the whole
+# directory rather than the scripts that happen to have a test today.
+/config/scripts/**/*.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skill-stubs/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 80d3d42a8bb..50062161da6 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -623,6 +623,8 @@ jobs:
needs: [code_paths]
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
+ # Let the serial Docker gates reach their own deadlines and report cleanup failures.
+ timeout-minutes: 90
steps:
- name: Checkout
@@ -678,14 +680,45 @@ jobs:
- name: Build native components
run: pnpm run build:native
+ - name: Install Linux package tooling
+ run: sudo apt-get update && sudo apt-get install -y cpio rpm
+
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
- run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
+ run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish never
+
+ - name: Verify root-package marker payloads
+ run: |
+ set -euo pipefail
+ version="$(node -p "require('./package.json').version")"
+ deb="dist/orca-ide_${version}_amd64.deb"
+ rpm="dist/orca-ide-${version}.x86_64.rpm"
+ test -s "$deb"
+ test -s "$rpm"
+ deb_marker="$(dpkg-deb --fsys-tarfile "$deb" | tar -xOf - ./opt/Orca/resources/package-type)"
+ rpm_marker="$(rpm2cpio "$rpm" | cpio --quiet --extract --to-stdout ./opt/Orca/resources/package-type)"
+ [[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; }
+ [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; }
- name: Verify headless serve signal shutdown
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
+ - name: Verify extracted launcher serve signal shutdown
+ run: >-
+ node config/scripts/run-headless-serve-shutdown-docker.mjs
+ --appimage dist/orca-linux.AppImage --entrypoint launcher
+
+ - name: Verify AppImage CLI registration and serve signal shutdown
+ run: >-
+ node config/scripts/run-headless-serve-shutdown-docker.mjs
+ --appimage dist/orca-linux.AppImage --entrypoint appimage
+ --signal-target serving-electron --int-delivery pid
+
+ # A default container reproduces the hostile AppImage launch environment.
+ - name: Verify Linux CLI launch contract
+ run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage
+
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
@@ -864,6 +897,9 @@ jobs:
contents: read
uses: ./.github/workflows/e2e.yml
with:
+ # The synthetic pull-request merge ref can disappear while this reusable
+ # workflow is queued. The head SHA is immutable and works for every PR.
+ ref: ${{ github.event.pull_request.head.sha }}
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 9bc7d415d7b..6f888c3a512 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -922,9 +922,7 @@ jobs:
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
- pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
- pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
@@ -940,6 +938,9 @@ jobs:
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
+ # The full suite is release-blocking on macOS. Windows still produces the
+ # same evidence, but intermittent filesystem contention cannot block signing.
+ continue-on-error: ${{ matrix.platform == 'windows' }}
timeout-minutes: 20
strategy:
fail-fast: false
diff --git a/.gitignore b/.gitignore
index e3fd07e45d1..3fb72a6486a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,6 +110,7 @@ docs/**
!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
+!docs/reference/windows-edr-posture.md
!docs/reference/windows-process-enumeration.md
!docs/reference/wsl-runner-verification.md
!docs/reference/remote-wire-compatibility.md
diff --git a/AGENTS.md b/AGENTS.md
index 9817cc41cc8..8b0156ba6b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,6 +49,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
+- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them.
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
diff --git a/README.md b/README.md
index dfb676bcef5..0f99bfc877f 100644
--- a/README.md
+++ b/README.md
@@ -238,9 +238,8 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
-- **WeChat:** Scan to join the Orca community WeChat group 7. If it is full, use group 8.
+- **WeChat:** Scan to join the Orca community WeChat group 8.
-
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
@@ -262,6 +261,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
## Signed Builds
+
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
diff --git a/config/docker/cli-launch-contract/Dockerfile b/config/docker/cli-launch-contract/Dockerfile
new file mode 100644
index 00000000000..f6a618a8ece
--- /dev/null
+++ b/config/docker/cli-launch-contract/Dockerfile
@@ -0,0 +1,34 @@
+ARG BASE_IMAGE=ubuntu:24.04
+FROM ${BASE_IMAGE}
+
+ARG LIBASOUND_PACKAGE=libasound2t64
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Install Electron's link-time libraries without adding a display server or FUSE.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ bash \
+ ca-certificates \
+ coreutils \
+ "${LIBASOUND_PACKAGE}" \
+ libatk-bridge2.0-0 \
+ libatspi2.0-0 \
+ libdrm2 \
+ libgbm1 \
+ libgtk-3-0 \
+ libnss3 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxfixes3 \
+ libxkbcommon0 \
+ libxrandr2 \
+ procps \
+ util-linux \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN useradd --create-home --shell /bin/bash orca
+
+COPY run-cli-case.sh /usr/local/bin/run-cli-case
+
+ENTRYPOINT ["/usr/local/bin/run-cli-case"]
diff --git a/config/docker/cli-launch-contract/run-cli-case.sh b/config/docker/cli-launch-contract/run-cli-case.sh
new file mode 100755
index 00000000000..3293601f22f
--- /dev/null
+++ b/config/docker/cli-launch-contract/run-cli-case.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Print a parseable verdict; the host script owns expected statuses.
+set -uo pipefail
+
+case_name=${1:?launch case is required}
+extracted_root=${ORCA_TEST_EXTRACTED_ROOT:-/artifacts/squashfs-root}
+launcher="$extracted_root/resources/bin/orca-ide"
+command_timeout_seconds=${ORCA_TEST_COMMAND_TIMEOUT_SECONDS:-60}
+
+if ((EUID == 0)); then
+ # Reproduce extracted AppImage sandbox ownership as an unprivileged user.
+ exec runuser --user orca --preserve-environment -- "$0" "$@"
+fi
+
+# Guard the restricted-userns precondition instead of accepting a false pass.
+if [[ "$case_name" == *-userns-* ]]; then
+ if unshare -Ur true 2>/dev/null; then
+ echo "PRECONDITION_FAILED user namespaces are available; this case needs them restricted"
+ exit 90
+ fi
+fi
+if [[ "$case_name" == nofuse-* && -e /dev/fuse ]]; then
+ echo "PRECONDITION_FAILED /dev/fuse is present; this case needs it absent"
+ exit 90
+fi
+
+unset DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR
+if [[ "$case_name" == stale-display-* ]]; then
+ DISPLAY=:77
+ export DISPLAY
+fi
+
+case "$case_name" in
+ # The bundled launcher must stay in Electron's node mode.
+ nofuse-userns-bundled-help) command=("$launcher" --help) ;;
+ nofuse-userns-bundled-version) command=("$launcher" --version) ;;
+ nofuse-userns-bundled-status) command=("$launcher" status) ;;
+ nofuse-userns-bundled-skills) command=("$launcher" skills --help) ;;
+ nofuse-userns-bundled-worktree) command=("$launcher" worktree list) ;;
+ # Direct binaries must hand off before Ozone initializes.
+ nofuse-nosandbox-direct-binary-skills)
+ command=("$extracted_root/orca-ide" --no-sandbox skills --help)
+ ;;
+ nofuse-nosandbox-direct-binary-gui)
+ command=("$extracted_root/orca-ide" --no-sandbox)
+ ;;
+ stale-display-nosandbox-direct-binary-gui)
+ command=("$extracted_root/orca-ide" --no-sandbox)
+ ;;
+ *)
+ echo "UNKNOWN_CASE $case_name"
+ exit 91
+ ;;
+esac
+
+output=$(timeout --foreground --signal=TERM --kill-after=5s "${command_timeout_seconds}s" "${command[@]}" 2>&1)
+status=$?
+
+if ((status == 124)); then
+ echo "TIMED_OUT seconds=$command_timeout_seconds case=$case_name"
+ printf '%s\n' "$output" | tail -30
+ exit 94
+fi
+
+if [[ "$case_name" == nofuse-userns-bundled-version ]]; then
+ version_file="$extracted_root/resources/app.asar.unpacked/out/package.json"
+ expected_version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file")
+ if [[ -z "$expected_version" || "$output" != "$expected_version" ]]; then
+ output="VERSION_MISMATCH expected=${expected_version:-missing} got=$output"
+ status=93
+ fi
+fi
+
+# Shell signal exits are reported as 128 plus the signal number.
+if ((status >= 128)); then
+ echo "CRASHED status=$status case=$case_name"
+ printf '%s\n' "$output" | tail -30
+ exit 92
+fi
+
+echo "RESULT status=$status case=$case_name"
+# Preserve the help header used by output assertions.
+printf '%s\n' "$output" | head -200
+exit 0
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/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh
index 3cbf594ae1e..2d561629170 100755
--- a/config/docker/headless-serve-shutdown/run-signal-case.sh
+++ b/config/docker/headless-serve-shutdown/run-signal-case.sh
@@ -6,7 +6,9 @@ app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
-startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
+# Packaged Electron startup can approach 90s on a cold CI runner; leave room
+# for the readiness line to reach the log before the observer deadline.
+startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}
if ((EUID == 0)); then
exec runuser --user orca --preserve-environment -- "$0" "$@"
@@ -41,8 +43,8 @@ chmod 700 "$XDG_RUNTIME_DIR"
case "$entrypoint_kind" in
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
+ appimage) entrypoint=(/input/orca.AppImage --appimage-extract-and-run --no-sandbox) ;;
launcher)
- export ELECTRON_DISABLE_SANDBOX=1
entrypoint=("$app_root/resources/bin/orca-ide")
;;
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
@@ -53,18 +55,50 @@ setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.
app_pid=$!
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
-# The inner shell expands its positional parameters.
-# shellcheck disable=SC2016
-ready_line=$(timeout "$startup_timeout_seconds" bash -c '
- tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
- | jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
-' bash "$app_pid" "$stdout_log" || true)
+# jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F
+# observer can outlive the timeout and leak into the next signal case. Poll
+# finite snapshots instead; each parser invocation has a definite EOF.
+read_ready_line() {
+ sed -u -n 's/^[^{]*//p' "$stdout_log" \
+ | jq --unbuffered -Rnc 'first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))'
+}
+
+ready_line=''
+startup_deadline=$((SECONDS + startup_timeout_seconds))
+while (( SECONDS < startup_deadline )); do
+ ready_line=$(read_ready_line)
+ [[ -n "$ready_line" ]] && break
+ kill -0 "$app_pid" 2>/dev/null || break
+ sleep 1
+done
+# A readiness event can land as the final poll races the write.
+if [[ -z "$ready_line" ]]; then
+ ready_line=$(read_ready_line)
+fi
if [[ -z "$ready_line" ]]; then
cat "$stdout_log" "$stderr_log" >&2
- echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
+ echo "FAIL: entrypoint exited or timed out before orca_server_ready" >&2
exit 1
fi
+registered_cli_verified=false
+if [[ "$entrypoint_kind" == appimage ]]; then
+ registered_cli="$HOME/.local/bin/orca-ide"
+ expected_target="$XDG_CACHE_HOME/orca/appimage/launcher/orca-ide"
+ actual_target=$(readlink "$registered_cli" 2>/dev/null || true)
+ if [[ "$actual_target" != "$expected_target" ]]; then
+ echo "FAIL: registered CLI target is ${actual_target:-missing}; expected $expected_target" >&2
+ exit 1
+ fi
+ if ! registered_help=$("$registered_cli" --help 2>&1) \
+ || [[ "$registered_help" != *'Usage: orca '* ]]; then
+ echo "FAIL: registered CLI did not execute the packaged help command" >&2
+ printf '%s\n' "$registered_help" >&2
+ exit 1
+ fi
+ registered_cli_verified=true
+fi
+
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
bound_port=${bound_endpoint##*:}
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
@@ -72,6 +106,7 @@ if [[ -z "$listener_before" ]]; then
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
exit 1
fi
+listener_before_pids=$(grep -oE 'pid=[0-9]+' <<<"$listener_before" | cut -d= -f2 || true)
tree_pids=()
declare -A tree_start_ticks
@@ -104,8 +139,15 @@ fi
signal_target_pid=$app_pid
if [[ "$signal_target_kind" == serving-electron ]]; then
- signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
+ # The ready socket identifies the serving Electron even when AppImage's
+ # extraction wrapper rewrites the command line before it reaches Chromium.
+ signal_target_pid=$(head -n1 <<<"$listener_before_pids")
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
+ if [[ -z "${tree_start_ticks[$signal_target_pid]+present}" ]]; then
+ echo "FAIL: ready listener PID $signal_target_pid is outside the entrypoint process tree" >&2
+ echo "listener: $listener_before" >&2
+ exit 1
+ fi
elif [[ "$signal_target_kind" != app ]]; then
echo "unsupported signal target: $signal_target_kind" >&2
exit 64
@@ -138,17 +180,25 @@ fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
-listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
-survivors=()
-for pid in "${tree_pids[@]}"; do
- if [[ -r "/proc/$pid/stat" ]] \
- && [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
- && ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
- survivors+=("$pid")
+# Crashpad can exit just after Electron; poll all owned shutdown state for up to 5s.
+for shutdown_poll in {0..50}; do
+ listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
+ survivors=()
+ for pid in "${tree_pids[@]}"; do
+ if [[ -r "/proc/$pid/stat" ]] \
+ && [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
+ && ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
+ survivors+=("$pid")
+ fi
+ done
+ owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
+ '($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
+ if [[ -z "$listener_after" && -z "$owned_residue" ]] \
+ && ((${#survivors[@]} == 0)); then
+ break
fi
+ ((shutdown_poll < 50)) && sleep 0.1
done
-owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
- '($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
canary_alive=false
if kill -0 "$canary_pid" 2>/dev/null \
@@ -170,16 +220,18 @@ jq -nc \
--argjson signalTargetPid "$signal_target_pid" \
--arg endpoint "$bound_endpoint" \
--arg listenerBefore "$listener_before" \
+ --arg listenerBeforePids "$listener_before_pids" \
--arg listenerAfter "$listener_after" \
--arg xvfbPids "$xvfb_pids" \
--arg treeBefore "$tree_snapshot" \
--argjson waitStatus "$wait_status" \
--argjson fatalEvidence "$fatal_evidence" \
--argjson canaryAlive "$canary_alive" \
+ --argjson registeredCliVerified "$registered_cli_verified" \
--arg survivors "${survivors[*]:-}" \
--arg residue "$owned_residue" \
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
- '{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
+ '{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerBeforePids:$listenerBeforePids,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,registeredCliVerified:$registeredCliVerified,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 13633ed8e01..06d41bad344 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -1,4 +1,4 @@
-const { chmodSync, existsSync, readdirSync } = require('node:fs')
+const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.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
@@ -104,6 +105,29 @@ const winSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-win-x64',
to: 'node_modules/sherpa-onnx-win-x64'
}
+// electron-builder replaces these defaults when `depends` is configured; retain
+// Electron's loader requirements alongside Orca's headless-host dependencies.
+const debElectronRuntimeDependencies = [
+ 'libgtk-3-0',
+ 'libnotify4',
+ 'libnss3',
+ 'libxss1',
+ 'libxtst6',
+ 'xdg-utils',
+ 'libatspi2.0-0',
+ 'libuuid1',
+ 'libsecret-1-0'
+]
+const rpmElectronRuntimeDependencies = [
+ 'gtk3',
+ 'libnotify',
+ 'nss',
+ 'libXScrnSaver',
+ '(libXtst or libXtst6)',
+ 'xdg-utils',
+ 'at-spi2-core',
+ '(libuuid or libuuid1)'
+]
// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build.
// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with
@@ -115,6 +139,7 @@ module.exports = {
appId,
productName: 'Orca',
protocols: [{ name: 'Orca', schemes: ['orca'] }],
+ toolsets: { appimage: '1.0.3' },
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
@@ -235,12 +260,21 @@ 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).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
- verifyLinuxGlibcFloor(context.appOutDir)
+ // Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary,
+ // so a cross-built slice could ship the host's pty.node and only fail at runtime.
+ verifyLinuxGlibcFloor(context.appOutDir, {
+ targetArch: { 1: 'x64', 3: 'arm64' }[context.arch]
+ })
}
const resourcesDir =
context.electronPlatformName === 'darwin'
@@ -254,6 +288,10 @@ module.exports = {
if (!existsSync(resourcesDir)) {
throw new Error(`Missing packaged resources directory: ${resourcesDir}`)
}
+ // FpmTarget replaces this with deb/rpm while building those artifacts from the shared app tree.
+ if (context.electronPlatformName === 'linux') {
+ writeFileSync(join(resourcesDir, 'package-type'), 'AppImage')
+ }
if (context.electronPlatformName === 'darwin') {
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
const architecture = architectureByEnum[context.arch]
@@ -273,6 +311,7 @@ module.exports = {
}
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
}
+ stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version)
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
@@ -522,7 +561,8 @@ module.exports = {
},
featureWallResources
],
- target: ['AppImage', 'deb'],
+ // Keep local artifacts aligned with the release pipeline.
+ target: ['AppImage', 'deb', 'rpm'],
maintainer: 'stablyai',
category: 'Utility'
},
@@ -536,6 +576,7 @@ module.exports = {
// Linux host — Chromium needs a display server even for offscreen rendering,
// and serve starts Xvfb itself when present (see ensure-virtual-display.ts).
depends: [
+ ...debElectronRuntimeDependencies,
'python3',
'python3-gi',
'gir1.2-atspi-2.0',
@@ -557,9 +598,9 @@ module.exports = {
// Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there
// is no `xvfb` package), so the name differs from the deb here.
depends: [
+ ...rpmElectronRuntimeDependencies,
'python3',
'python3-gobject',
- 'at-spi2-core',
'xdotool',
'xclip',
'xorg-x11-server-Xvfb'
@@ -584,6 +625,16 @@ module.exports = {
}
}
+// Stamp the effective channel version where node-mode CLI code can read it.
+function stampPackagedCliVersion(resourcesDir, version) {
+ const packageJsonPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json')
+ if (!existsSync(packageJsonPath)) {
+ throw new Error(`Missing unpacked CLI package boundary: ${packageJsonPath}`)
+ }
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
+ writeFileSync(packageJsonPath, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`)
+}
+
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
if (electronPlatformName === 'win32') {
return
diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch
index 9ee2ebd39b4..348ce6ef7ce 100644
--- a/config/patches/node-pty@1.1.0.patch
+++ b/config/patches/node-pty@1.1.0.patch
@@ -176,7 +176,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
-index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
+index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a52c690e0a 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -215,7 +215,17 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
-@@ -237,13 +258,23 @@ pty_getproc(int, char *);
+@@ -228,6 +249,9 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info);
+ static int
+ pty_nonblock(int);
+
++static int
++pty_cloexec(int);
++
+ #if defined(__APPLE__)
+ static char *
+ pty_getproc(int);
+@@ -237,13 +261,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
@@ -240,7 +250,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
#endif
struct DelBuf {
-@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
+@@ -367,14 +401,18 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
@@ -256,7 +266,48 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
-@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
+ }
++ if (pty_cloexec(master) == -1) {
++ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
++ }
+ #else
+ int argc = argv_.Length();
+ int argl = argc + 2;
+@@ -445,6 +483,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
+ if (pty_nonblock(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
+ }
++ if (pty_cloexec(master) == -1) {
++ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
++ }
+ }
+ #endif
+
+@@ -586,6 +627,23 @@ pty_nonblock(int fd) {
+ return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+ }
+
++/**
++ * Orca: close-on-exec FD
++ *
++ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without
++ * FD_CLOEXEC is inherited by every later child of this process -- including
++ * later pty children -- which keeps its /dev/pts device and buffers alive long
++ * after its own session ends (#8362).
++ */
++
++static int
++pty_cloexec(int fd) {
++ int flags = fcntl(fd, F_GETFD);
++ if (flags == -1) return -1;
++ if (flags & FD_CLOEXEC) return 0;
++ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
++}
++
+ /**
+ * pty_getproc
+ * Taken from tmux.
+@@ -684,15 +742,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -332,7 +383,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
-@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
+@@ -706,80 +822,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs
new file mode 100644
index 00000000000..f4f4f87619a
--- /dev/null
+++ b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs
@@ -0,0 +1,318 @@
+/**
+ * Relay-side pty-master close-on-exec patch for node-pty 1.1.0 (#17915).
+ *
+ * The app gets this through pnpm `patchedDependencies`; the relay installs stock
+ * node-pty from npm onto the host, where no pnpm patch reaches. Without it every
+ * later child of the relay -- pty children, git helpers, probes, agent CLIs --
+ * inherits each live master fd and keeps its /dev/pts device alive for the life
+ * of the relay (#8362).
+ *
+ * Linux only, deliberately: it is the only relay platform that takes forkpty()'s
+ * no-atomic-O_CLOEXEC path, and the only one that already compiles node-pty at
+ * install time, so the rebuild costs a second compile rather than a first one.
+ * macOS re-opens the tty through uv_tty_init's cloexec dup and Windows has no fds.
+ *
+ * Non-fatal by construction: the working build is moved aside before anything is
+ * touched and moved back on any failure, and a failed attempt drops a skip marker
+ * so the compile is attempted at most once per relay directory.
+ */
+
+const { spawnSync } = require('node:child_process')
+const { createHash } = require('node:crypto')
+const {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync
+} = require('node:fs')
+const { dirname, join, resolve } = require('node:path')
+
+const EXPECTED_NODE_PTY_VERSION = '1.1.0'
+const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6'
+const PATCHED_SOURCE_SHA256 = '97dea52199216c01b62070758f0f38621ae53adc16c221271dd35ae2d8ee3482'
+
+const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:'
+const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip'
+const BACKUP_DIRNAME = '.orca-cloexec-prepatch-release'
+// Under the caller's 240s SSH command timeout, so the rollback below still runs.
+const REBUILD_TIMEOUT_MS = 200000
+const VERIFY_TIMEOUT_MS = 15000
+
+const FORWARD_DECLARATION = [
+ 'static int\npty_nonblock(int);\n',
+ 'static int\npty_nonblock(int);\n\nstatic int\npty_cloexec(int);\n'
+]
+
+const DEFINITION = [
+ `static int
+pty_nonblock(int fd) {
+ int flags = fcntl(fd, F_GETFL, 0);
+ if (flags == -1) return -1;
+ return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+}
+`,
+ `static int
+pty_nonblock(int fd) {
+ int flags = fcntl(fd, F_GETFL, 0);
+ if (flags == -1) return -1;
+ return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+}
+
+/**
+ * Orca: close-on-exec FD
+ *
+ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without
+ * FD_CLOEXEC is inherited by every later child of this process -- including
+ * later pty children -- which keeps its /dev/pts device and buffers alive long
+ * after its own session ends (#8362).
+ */
+
+static int
+pty_cloexec(int fd) {
+ int flags = fcntl(fd, F_GETFD);
+ if (flags == -1) return -1;
+ if (flags & FD_CLOEXEC) return 0;
+ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
+}
+`
+]
+
+const FORKPTY_CALL_SITE = [
+ ` default:
+ if (pty_nonblock(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
+ }
+ }
+`,
+ ` default:
+ if (pty_nonblock(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
+ }
+ if (pty_cloexec(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
+ }
+ }
+`
+]
+
+const REPLACEMENTS = [FORWARD_DECLARATION, DEFINITION, FORKPTY_CALL_SITE]
+
+function sourceSha256(source) {
+ return createHash('sha256').update(source).digest('hex')
+}
+
+function nodePtyDir(relayDir) {
+ return resolve(relayDir, 'node_modules', 'node-pty')
+}
+
+function inspectNodePtyUnixSource(relayDir) {
+ const ptyDir = nodePtyDir(relayDir)
+ const sourcePath = join(ptyDir, 'src', 'unix', 'pty.cc')
+ const version = JSON.parse(readFileSync(join(ptyDir, 'package.json'), 'utf8')).version
+ if (version !== EXPECTED_NODE_PTY_VERSION) {
+ throw new Error(`Refusing to patch node-pty ${version}; expected ${EXPECTED_NODE_PTY_VERSION}`)
+ }
+ return { ptyDir, sourcePath, source: readFileSync(sourcePath, 'utf8') }
+}
+
+function writeSourceAtomically(sourcePath, contents) {
+ const temporaryPath = `${sourcePath}.orca-patch-${process.pid}`
+ // Why: a terminated install must leave one of the two known source versions on disk.
+ try {
+ writeFileSync(temporaryPath, contents)
+ renameSync(temporaryPath, sourcePath)
+ } finally {
+ rmSync(temporaryPath, { force: true })
+ }
+}
+
+function rewriteSource(source, reverse) {
+ let rewritten = source
+ for (const [original, patched] of REPLACEMENTS) {
+ const from = reverse ? patched : original
+ const to = reverse ? original : patched
+ if (rewritten.split(from).length - 1 !== 1) {
+ throw new Error('Refusing to rewrite unexpected node-pty pty.cc source')
+ }
+ rewritten = rewritten.replace(from, to)
+ }
+ return rewritten
+}
+
+/** True when the patch was applied, false when it was already installed. */
+function patchNodePtyMasterCloexecSource(relayDir = process.cwd()) {
+ const inspected = inspectNodePtyUnixSource(relayDir)
+ const hash = sourceSha256(inspected.source)
+ if (hash === PATCHED_SOURCE_SHA256) {
+ return false
+ }
+ if (hash !== ORIGINAL_SOURCE_SHA256) {
+ throw new Error('Refusing to patch unexpected node-pty pty.cc source')
+ }
+ writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, false))
+ assertPatchedNodePtyMasterCloexecSource(relayDir)
+ return true
+}
+
+function assertPatchedNodePtyMasterCloexecSource(relayDir = process.cwd()) {
+ const inspected = inspectNodePtyUnixSource(relayDir)
+ if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
+ throw new Error('node-pty pty master close-on-exec patch is not installed')
+ }
+}
+
+function revertNodePtyMasterCloexecSource(relayDir = process.cwd()) {
+ const inspected = inspectNodePtyUnixSource(relayDir)
+ if (sourceSha256(inspected.source) === ORIGINAL_SOURCE_SHA256) {
+ return false
+ }
+ writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, true))
+ return true
+}
+
+function rebuildNodePty(relayDir) {
+ const result = spawnSync('npm', ['rebuild', '--ignore-scripts=false', 'node-pty'], {
+ cwd: relayDir,
+ encoding: 'utf8',
+ timeout: REBUILD_TIMEOUT_MS,
+ windowsHide: true
+ })
+ if (result.error) {
+ throw new Error(`npm rebuild node-pty failed: ${result.error.message}`)
+ }
+ if (result.status !== 0) {
+ const tail = `${result.stdout || ''}${result.stderr || ''}`.trim().slice(-300)
+ throw new Error(`npm rebuild node-pty exited ${result.status ?? result.signal}: ${tail}`)
+ }
+}
+
+// Why a child: a bad build can abort the process on require, which would strand the
+// moved-aside working build. Why the reachability check: a host without /proc cannot
+// show inheritance, and an unobservable flag is not evidence the rebuild was wrong.
+const VERIFY_SCRIPT = `
+const pty = require(process.argv[1]);
+const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
+ name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env
+});
+const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l /proc/self/fd'], { encoding: 'utf8' });
+try { term.kill() } catch {}
+const listing = probe.stdout || '';
+if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) }
+console.log(listing.includes('ptmx') ? 'INHERITED' : 'ISOLATED');
+process.exit(0);
+`
+
+/** 'isolated' when a later plain child no longer inherits the master, 'unverified' when /proc cannot say. */
+function verifyMasterNotInheritedByLaterChild(relayDir) {
+ const result = spawnSync(process.execPath, ['-e', VERIFY_SCRIPT, nodePtyDir(relayDir)], {
+ cwd: relayDir,
+ encoding: 'utf8',
+ timeout: VERIFY_TIMEOUT_MS,
+ windowsHide: true
+ })
+ const output = `${result.stdout || ''}`
+ if (result.status !== 0 || result.error) {
+ const tail = `${output}${result.stderr || ''}`.trim().slice(-300)
+ throw new Error(
+ `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}`
+ )
+ }
+ if (output.includes('INHERITED')) {
+ throw new Error('rebuilt node-pty still leaks the pty master into later children')
+ }
+ return output.includes('ISOLATED') ? 'isolated' : 'unverified'
+}
+
+function rollback(relayDir, releaseDir, backupDir) {
+ rmSync(releaseDir, { recursive: true, force: true })
+ try {
+ revertNodePtyMasterCloexecSource(relayDir)
+ } catch {
+ // The build that is about to be restored predates the patch either way.
+ }
+ if (existsSync(backupDir)) {
+ mkdirSync(dirname(releaseDir), { recursive: true })
+ renameSync(backupDir, releaseDir)
+ }
+}
+
+/**
+ * Patch and rebuild the host's node-pty, or leave it exactly as found.
+ * Never throws: the caller is on the connect path and a leaky relay beats no relay.
+ */
+function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) {
+ const platform = options.platform || process.platform
+ const rebuild = options.rebuild || rebuildNodePty
+ const verify = options.verify || verifyMasterNotInheritedByLaterChild
+ if (platform !== 'linux') {
+ return 'skipped:not-linux'
+ }
+ const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME)
+ if (existsSync(skipMarkerPath)) {
+ return 'skipped:earlier-attempt-failed'
+ }
+ const releaseDir = join(nodePtyDir(relayDir), 'build', 'Release')
+ const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME)
+ // A backup stranded by a connection that died mid-rebuild is stale by definition:
+ // whatever repaired node-pty since built from the source now on disk.
+ rmSync(backupDir, { recursive: true, force: true })
+
+ let inspected
+ try {
+ inspected = inspectNodePtyUnixSource(relayDir)
+ } catch (err) {
+ return `skipped:${err.message}`
+ }
+ const hash = sourceSha256(inspected.source)
+ if (hash === PATCHED_SOURCE_SHA256) {
+ return 'already-patched'
+ }
+ if (hash !== ORIGINAL_SOURCE_SHA256) {
+ return 'skipped:unexpected-source'
+ }
+ // No compiled build means the host runs a prebuild or nothing at all; rebuilding
+ // could only take away the artifact the probe just proved loadable.
+ if (!existsSync(join(releaseDir, 'pty.node'))) {
+ return 'skipped:no-compiled-build'
+ }
+
+ try {
+ renameSync(releaseDir, backupDir)
+ } catch (err) {
+ return `skipped:${err.message}`
+ }
+ try {
+ patchNodePtyMasterCloexecSource(relayDir)
+ rebuild(relayDir)
+ const verdict = verify(relayDir)
+ rmSync(backupDir, { recursive: true, force: true })
+ return verdict === 'isolated' ? 'patched' : 'patched-unverified'
+ } catch (err) {
+ rollback(relayDir, releaseDir, backupDir)
+ // Bounded on purpose: one compile attempt per relay directory, never a retry loop.
+ try {
+ writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`)
+ } catch {
+ // A relay dir we cannot write to will fail the cheap checks above next time anyway.
+ }
+ return `failed:${err.message}`
+ }
+}
+
+if (require.main === module) {
+ console.log(`${STATUS_PREFIX}${applyNodePtyMasterCloexecPatch()}`)
+}
+
+module.exports = {
+ EXPECTED_NODE_PTY_VERSION,
+ ORIGINAL_SOURCE_SHA256,
+ PATCHED_SOURCE_SHA256,
+ SKIP_MARKER_FILENAME,
+ STATUS_PREFIX,
+ applyNodePtyMasterCloexecPatch,
+ assertPatchedNodePtyMasterCloexecSource,
+ patchNodePtyMasterCloexecSource,
+ revertNodePtyMasterCloexecSource
+}
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 0c2337ba36f..c36412c0383 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -13863,6 +13863,90 @@
"knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."],
"demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped."
},
+ {
+ "id": "terminal-render.foreground-repair-span",
+ "title": "A forced foreground repaint covers every row the write changed",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-rendering",
+ "layer": "renderer-unit",
+ "surfaces": [
+ "foreground PTY output",
+ "in-place agent redraws",
+ "erase-in-line/display",
+ "alternate screen",
+ "scroll",
+ "wide glyphs"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": [],
+ "coverageNotes": "Renderer-unit convergence corpus over a real xterm parser, plus manual CDP pixel evidence on the macOS WebGL renderer. The repaint span is provider-independent because it is computed from xterm's parse, not from the transport; SSH/WSL/remote were not exercised live.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/pull/2669",
+ "https://github.com/stablyai/orca/pull/4669",
+ "https://github.com/stablyai/orca/pull/8178"
+ ],
+ "invariant": "The row span Orca asks xterm to repaint after a forced foreground refresh must cover every viewport row whose rendered content changed during that write, plus the cursor row before and after it; when the span cannot be established — unobservable parse, viewport scroll, or a normal/alternate buffer flip — the whole viewport must be repainted.",
+ "oracle": "A real @xterm/headless parser replays an adversarial corpus (in-place bottom-row redraws, standalone CR overwrite, backspace, erase-in-line, erase-in-display above and below the cursor, full clear, wide CJK, emoji, combining marks, ZWJ sequences, scroll-region insert/delete, reverse index, DEC 2026 frames, alternate-screen enter and exit, viewport scroll, narrow panes). Each viewport row is serialized cell-by-cell with its attributes before and after the write, and every row that differs must fall inside the span the settle path requested. A vacuity guard asserts each case actually moves the screen.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts"
+ ],
+ "testFiles": [
+ "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts",
+ "assertions": [
+ "every viewport row whose serialized cells changed lies inside the requested repaint span",
+ "the cursor row before and after the write is inside the requested repaint span",
+ "viewport scroll and alternate-screen transitions still request the whole grid",
+ "an unobservable parse span falls back to the whole grid",
+ "an in-place bottom-row redraw narrows well below the full grid"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-09-02",
+ "runner": "local",
+ "platform": "macos",
+ "result": "passed",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts",
+ "durationSeconds": 1,
+ "summary": "25 cases passed against a real xterm parser; paired CDP run on a 4-pane macOS WebGL dev build produced screenshots byte-identical to a forced full model rebuild."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 15,
+ "scope": "Renderer-unit convergence corpus"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "New deterministic gate; no soak history yet."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "Narrowing the span to the cursor rows alone (dropping xterm's parse span) fails the claude-style in-place redraw and erase-in-display-above cases; reading buffer indices instead of viewport rows made the corpus vacuous and is now blocked by the changed-row guard."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Measured on a focused, visible 4-pane macOS dev build under an agent-style in-place redraw load: rendered cells/s 157,708 -> 30,139 and forEachDecorationAtCell 320,868/s -> 60,652/s with render frames/s unchanged (59.8 -> 60.5)."
+ },
+ "promotionCriteria": [
+ "Add Windows DOM-renderer coverage for the synchronous repair branch.",
+ "Wire pixel or cell evidence for the alternate-screen and reflow paths into CI rather than manual CDP runs.",
+ "Keep a full-grid fallback assertion for every new span-narrowing condition."
+ ],
+ "knownGaps": [
+ "No CI-wired pixel oracle; WebGL convergence evidence was collected manually over CDP.",
+ "Windows ConPTY synchronous repair path is covered only by the shared corpus, not on a Windows runner.",
+ "SSH/WSL/remote providers were not exercised live; the span is transport-independent by construction."
+ ],
+ "demotionRule": "Demote or block if a narrowing condition is added without a matching convergence case, if the corpus stops asserting that each case changes at least one row, or if a repaint regression is reported for in-place agent redraws."
+ },
{
"id": "terminal-shell.windows-resolution-parity",
"title": "Windows local and daemon providers resolve shells and startup commands consistently",
@@ -16701,16 +16785,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"
],
@@ -16718,7 +16803,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": [
{
@@ -16735,15 +16821,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"
]
},
@@ -16754,6 +16844,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": [
@@ -16774,11 +16872,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",
@@ -16805,6 +16912,105 @@
],
"demotionRule": "Keep experimental or demote if either signal traps, returns nonzero, retains its listener/Xvfb/run-owned process identity, touches the unrelated canary, or the focused gate flakes without an identified product or harness defect."
},
+ {
+ "id": "runtime.linux-cli-launch-contract",
+ "title": "Packaged Linux CLI commands run without FUSE, user namespaces, or a display",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "runtime-platform",
+ "layer": "appimage-cli-entrypoint",
+ "surfaces": [
+ "packaged Linux AppImage",
+ "bundled CLI launcher",
+ "extracted direct binary",
+ "desktop launch diagnosis"
+ ],
+ "platforms": ["linux"],
+ "providers": ["local-daemon"],
+ "coveredPlatforms": ["linux"],
+ "coveredProviders": ["local-daemon"],
+ "coverageNotes": "A restricted Ubuntu container stages the extracted AppImage payload with no /dev/fuse and with unprivileged user namespaces denied, then runs eight CLI cases across the bundled launcher and the extracted direct binary. x64 only, because PR CI builds only --x64.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/issues/13719",
+ "https://github.com/stablyai/orca/issues/14229"
+ ],
+ "invariant": "On a host without FUSE and without unprivileged user namespaces, every packaged CLI entrypoint either completes its command or reports a diagnosis, and never terminates on a signal.",
+ "oracle": "Build the image, stage the AppImage payload, and run each case in the restricted container. Assert the preconditions first: unshare -Ur must fail and /dev/fuse must be absent, so a relaxed runner fails the job rather than silently skipping. For each case require the exact expected exit status and an expected substring of the command's own output, with the harness RESULT/CRASHED/PRECONDITION_FAILED control lines excluded so a case name can never satisfy its own assertion. Any status of 128 or above is a crash and fails immediately. The per-case timeout is a failure deadline, never a success condition.",
+ "commands": [
+ "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
+ "shellcheck config/docker/cli-launch-contract/run-cli-case.sh"
+ ],
+ "testFiles": [
+ "config/scripts/run-linux-cli-launch-contract-docker.mjs",
+ "config/docker/cli-launch-contract/run-cli-case.sh"
+ ],
+ "assertionRefs": [
+ {
+ "file": "config/scripts/run-linux-cli-launch-contract-docker.mjs",
+ "assertions": [
+ "the bundled launcher serves --help, --version, status, skills --help, and worktree list without Chromium",
+ "a direct binary launch reaching JavaScript runs the command instead of booting a GUI",
+ "a desktop launch with no display reports the missing-display diagnosis instead of trapping",
+ "a stale DISPLAY is diagnosed rather than trusted",
+ "expected output is matched against the command's own output, not the harness control lines"
+ ]
+ },
+ {
+ "file": "config/docker/cli-launch-contract/run-cli-case.sh",
+ "assertions": [
+ "the container refuses to run unless unprivileged user namespaces are denied and /dev/fuse is absent",
+ "an exit status of 128 or above is reported as a crash rather than compared to the expected status"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-08-31",
+ "runner": "ci",
+ "platform": "linux",
+ "command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
+ "result": "passed",
+ "durationSeconds": 40,
+ "summary": "PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 ran all eight cases to ok on ubuntu-latest. The unshare and /dev/fuse preconditions held under moby's default seccomp profile rather than tripping."
+ },
+ {
+ "date": "2026-09-01",
+ "runner": "local",
+ "platform": "linux",
+ "command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
+ "result": "passed",
+ "durationSeconds": 60,
+ "summary": "All eight cases passed on Ubuntu 24.04 amd64 hardware against an AppImage built from the stack tip, invoked against a copy of that artifact outside dist/."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 300,
+ "scope": "eight CLI launch cases in one restricted Ubuntu container"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "The harness is new; soak history is not yet available."
+ },
+ "redGreenEvidence": {
+ "status": "complete",
+ "evidence": "The same harness run against a stock release AppImage failed four of eight cases: nofuse-userns-bundled-version at status 93, and all three direct-binary cases crashed at status 133 (SIGTRAP, the uv_close abort of #13719 and #14229). The stack-tip AppImage passed all eight. Corroborated at artifact level: the stack-tip runtime is a static-pie ELF with no PT_INTERP, the stock runtime is dynamically linked. Caveat: the two skills cases previously asserted a substring that the harness's own RESULT line contained, so they passed independently of command output; both now assert the rendered help header, and the green runs above predate that change."
+ },
+ "performanceBudget": {
+ "required": false,
+ "evidence": "The gate is CI-only and adds no product code path."
+ },
+ "promotionCriteria": [
+ "Collect 30 consecutive CI passes or 14 days without an unexplained flake.",
+ "Extend the matrix to arm64 once PR CI builds that architecture.",
+ "Re-run red/green against a stock AppImage after any change to the launcher entrypoint."
+ ],
+ "knownGaps": [
+ "The preconditions depend on moby's default seccomp profile denying unshare(CLONE_NEWUSER) and on /dev/fuse being absent. A runner with a relaxed profile or a mounted /dev/fuse trips PRECONDITION_FAILED and fails the job rather than skipping.",
+ "x64 only: PR CI builds only --x64, so the arm64 launcher path is unexercised.",
+ "The harness covers CLI entrypoints only; it does not exercise a full desktop session."
+ ],
+ "demotionRule": "Keep experimental or demote if any case terminates on a signal, the preconditions stop holding on the CI runner, or an assertion can be satisfied by anything other than the command's own output."
+ },
{
"id": "ssh-managed-hooks.node18-runtime-compatibility",
"title": "SSH managed-hook companions load and install hooks on Node 18",
diff --git a/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc b/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc
new file mode 100644
index 00000000000..7b4b9e1f990
--- /dev/null
+++ b/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc
@@ -0,0 +1,799 @@
+/**
+ * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
+ * Copyright (c) 2017, Daniel Imms (MIT License)
+ *
+ * pty.cc:
+ * This file is responsible for starting processes
+ * with pseudo-terminal file descriptors.
+ *
+ * See:
+ * man pty
+ * man tty_ioctl
+ * man termios
+ * man forkpty
+ */
+
+/**
+ * Includes
+ */
+
+#define NODE_ADDON_API_DISABLE_DEPRECATED
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* forkpty */
+/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */
+#if defined(__linux__)
+#include
+#elif defined(__APPLE__)
+#include
+#elif defined(__FreeBSD__)
+#include
+#include
+#elif defined(__OpenBSD__)
+#include
+#include
+#endif
+
+/* Some platforms name VWERASE and VDISCARD differently */
+#if !defined(VWERASE) && defined(VWERSE)
+#define VWERASE VWERSE
+#endif
+#if !defined(VDISCARD) && defined(VDISCRD)
+#define VDISCARD VDISCRD
+#endif
+
+/* for pty_getproc */
+#if defined(__linux__)
+#include
+#include
+#elif defined(__APPLE__)
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#endif
+
+/* NSIG - macro for highest signal + 1, should be defined */
+#ifndef NSIG
+#define NSIG 32
+#endif
+
+/* macOS 10.14 back does not define this constant */
+#ifndef POSIX_SPAWN_SETSID
+ #define POSIX_SPAWN_SETSID 1024
+#endif
+
+/* environ for execvpe */
+/* node/src/node_child_process.cc */
+#if !defined(__APPLE__)
+extern char **environ;
+#endif
+
+#if defined(__APPLE__)
+extern "C" {
+// Changes the current thread's directory to a path or directory file
+// descriptor. libpthread only exposes a syscall wrapper starting in
+// macOS 10.12, but the system call dates back to macOS 10.5. On older OSes,
+// the syscall is issued directly.
+int pthread_chdir_np(const char* dir) API_AVAILABLE(macosx(10.12));
+int pthread_fchdir_np(int fd) API_AVAILABLE(macosx(10.12));
+}
+
+#define HANDLE_EINTR(x) ({ \
+ int eintr_wrapper_counter = 0; \
+ decltype(x) eintr_wrapper_result; \
+ do { \
+ eintr_wrapper_result = (x); \
+ } while (eintr_wrapper_result == -1 && errno == EINTR && \
+ eintr_wrapper_counter++ < 100); \
+ eintr_wrapper_result; \
+})
+#endif
+
+struct ExitEvent {
+ int exit_code = 0, signal_code = 0;
+};
+
+void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) {
+ std::thread *th = new std::thread;
+ // Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE.
+ auto tsfn = Napi::ThreadSafeFunction::New(
+ env,
+ cb, // JavaScript function called asynchronously
+ "SetupExitCallback_resource", // Name
+ 0, // Unlimited queue
+ 1, // Only one thread will use this initially
+ [th](Napi::Env) { // Finalizer used to clean threads up
+ th->join();
+ delete th;
+ });
+ *th = std::thread([tsfn = std::move(tsfn), pid] {
+ auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
+ cb.Call({Napi::Number::New(env, exit_event->exit_code),
+ Napi::Number::New(env, exit_event->signal_code)});
+ delete exit_event;
+ };
+
+ int ret;
+ int stat_loc;
+#if defined(__APPLE__)
+ // Based on
+ // https://source.chromium.org/chromium/chromium/src/+/main:base/process/kill_mac.cc;l=35-69?
+ int kq = HANDLE_EINTR(kqueue());
+ struct kevent change = {0};
+ EV_SET(&change, pid, EVFILT_PROC, EV_ADD, NOTE_EXIT, 0, NULL);
+ ret = HANDLE_EINTR(kevent(kq, &change, 1, NULL, 0, NULL));
+ if (ret == -1) {
+ if (errno == ESRCH) {
+ // At this point, one of the following has occurred:
+ // 1. The process has died but has not yet been reaped.
+ // 2. The process has died and has already been reaped.
+ // 3. The process is in the process of dying. It's no longer
+ // kqueueable, but it may not be waitable yet either. Mark calls
+ // this case the "zombie death race".
+ ret = HANDLE_EINTR(waitpid(pid, &stat_loc, WNOHANG));
+ if (ret == 0) {
+ ret = kill(pid, SIGKILL);
+ if (ret != -1) {
+ HANDLE_EINTR(waitpid(pid, &stat_loc, 0));
+ }
+ }
+ }
+ } else {
+ struct kevent event = {0};
+ ret = HANDLE_EINTR(kevent(kq, NULL, 0, &event, 1, NULL));
+ if (ret == 1) {
+ if ((event.fflags & NOTE_EXIT) &&
+ (event.ident == static_cast(pid))) {
+ // The process is dead or dying. This won't block for long, if at
+ // all.
+ HANDLE_EINTR(waitpid(pid, &stat_loc, 0));
+ }
+ }
+ }
+#else
+ while (true) {
+ errno = 0;
+ if ((ret = waitpid(pid, &stat_loc, 0)) != pid) {
+ if (ret == -1 && errno == EINTR) {
+ continue;
+ }
+ if (ret == -1 && errno == ECHILD) {
+ // XXX node v0.8.x seems to have this problem.
+ // waitpid is already handled elsewhere.
+ ;
+ } else {
+ assert(false);
+ }
+ }
+ break;
+ }
+#endif
+ ExitEvent *exit_event = new ExitEvent;
+ if (WIFEXITED(stat_loc)) {
+ exit_event->exit_code = WEXITSTATUS(stat_loc); // errno?
+ }
+ if (WIFSIGNALED(stat_loc)) {
+ exit_event->signal_code = WTERMSIG(stat_loc);
+ }
+ auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
+ switch (status) {
+ case napi_closing:
+ break;
+
+ case napi_queue_full:
+ Napi::Error::Fatal("SetupExitCallback", "Queue was full");
+
+ case napi_ok:
+ if (tsfn.Release() != napi_ok) {
+ Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed");
+ }
+ break;
+
+ default:
+ Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed");
+ }
+ });
+}
+
+/**
+ * Methods
+ */
+
+Napi::Value PtyFork(const Napi::CallbackInfo& info);
+Napi::Value PtyOpen(const Napi::CallbackInfo& info);
+Napi::Value PtyResize(const Napi::CallbackInfo& info);
+Napi::Value PtyGetProc(const Napi::CallbackInfo& info);
+
+/**
+ * Functions
+ */
+
+static int
+pty_nonblock(int);
+
+#if defined(__APPLE__)
+static char *
+pty_getproc(int);
+#else
+static char *
+pty_getproc(int, char *);
+#endif
+
+#if defined(__APPLE__) || defined(__OpenBSD__)
+static void
+pty_posix_spawn(char** argv, char** env,
+ const struct termios *termp,
+ const struct winsize *winp,
+ int* master,
+ pid_t* pid,
+ int* err);
+#endif
+
+struct DelBuf {
+ int len;
+ DelBuf(int len) : len(len) {}
+ void operator()(char **p) {
+ if (p == nullptr)
+ return;
+ for (int i = 0; i < len; i++)
+ free(p[i]);
+ delete[] p;
+ }
+};
+
+Napi::Value PtyFork(const Napi::CallbackInfo& info) {
+ Napi::Env napiEnv(info.Env());
+ Napi::HandleScope scope(napiEnv);
+
+ if (info.Length() != 11 ||
+ !info[0].IsString() ||
+ !info[1].IsArray() ||
+ !info[2].IsArray() ||
+ !info[3].IsString() ||
+ !info[4].IsNumber() ||
+ !info[5].IsNumber() ||
+ !info[6].IsNumber() ||
+ !info[7].IsNumber() ||
+ !info[8].IsBoolean() ||
+ !info[9].IsString() ||
+ !info[10].IsFunction()) {
+ throw Napi::Error::New(napiEnv, "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, helperPath, onexit)");
+ }
+
+ // file
+ std::string file = info[0].As();
+
+ // args
+ Napi::Array argv_ = info[1].As();
+
+ // env
+ Napi::Array env_ = info[2].As();
+ int envc = env_.Length();
+ std::unique_ptr env_unique_ptr(new char *[envc + 1], DelBuf(envc + 1));
+ char **env = env_unique_ptr.get();
+ env[envc] = NULL;
+ for (int i = 0; i < envc; i++) {
+ std::string pair = env_.Get(i).As();
+ env[i] = strdup(pair.c_str());
+ }
+
+ // cwd
+ std::string cwd_ = info[3].As();
+
+ // size
+ struct winsize winp;
+ winp.ws_col = info[4].As().Int32Value();
+ winp.ws_row = info[5].As().Int32Value();
+ winp.ws_xpixel = 0;
+ winp.ws_ypixel = 0;
+
+#if !defined(__APPLE__)
+ // uid / gid
+ int uid = info[6].As().Int32Value();
+ int gid = info[7].As().Int32Value();
+#endif
+
+ // termios
+ struct termios t = termios();
+ struct termios *term = &t;
+ term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT;
+ if (info[8].As().Value()) {
+#if defined(IUTF8)
+ term->c_iflag |= IUTF8;
+#endif
+ }
+ term->c_oflag = OPOST | ONLCR;
+ term->c_cflag = CREAD | CS8 | HUPCL;
+ term->c_lflag = ICANON | ISIG | IEXTEN | ECHO | ECHOE | ECHOK | ECHOKE | ECHOCTL;
+
+ term->c_cc[VEOF] = 4;
+ term->c_cc[VEOL] = -1;
+ term->c_cc[VEOL2] = -1;
+ term->c_cc[VERASE] = 0x7f;
+ term->c_cc[VWERASE] = 23;
+ term->c_cc[VKILL] = 21;
+ term->c_cc[VREPRINT] = 18;
+ term->c_cc[VINTR] = 3;
+ term->c_cc[VQUIT] = 0x1c;
+ term->c_cc[VSUSP] = 26;
+ term->c_cc[VSTART] = 17;
+ term->c_cc[VSTOP] = 19;
+ term->c_cc[VLNEXT] = 22;
+ term->c_cc[VDISCARD] = 15;
+ term->c_cc[VMIN] = 1;
+ term->c_cc[VTIME] = 0;
+
+ #if (__APPLE__)
+ term->c_cc[VDSUSP] = 25;
+ term->c_cc[VSTATUS] = 20;
+ #endif
+
+ cfsetispeed(term, B38400);
+ cfsetospeed(term, B38400);
+
+ // helperPath
+ std::string helper_path = info[9].As();
+
+ pid_t pid;
+ int master;
+#if defined(__APPLE__)
+ int argc = argv_.Length();
+ int argl = argc + 4;
+ std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl));
+ char **argv = argv_unique_ptr.get();
+ argv[0] = strdup(helper_path.c_str());
+ argv[1] = strdup(cwd_.c_str());
+ argv[2] = strdup(file.c_str());
+ argv[argl - 1] = NULL;
+ for (int i = 0; i < argc; i++) {
+ std::string arg = argv_.Get(i).As();
+ argv[i + 3] = strdup(arg.c_str());
+ }
+
+ int err = -1;
+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
+ if (err != 0) {
+ throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
+ }
+ if (pty_nonblock(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
+ }
+#else
+ int argc = argv_.Length();
+ int argl = argc + 2;
+ std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl));
+ char** argv = argv_unique_ptr.get();
+ argv[0] = strdup(file.c_str());
+ argv[argl - 1] = NULL;
+ for (int i = 0; i < argc; i++) {
+ std::string arg = argv_.Get(i).As();
+ argv[i + 1] = strdup(arg.c_str());
+ }
+
+ sigset_t newmask, oldmask;
+ struct sigaction sig_action;
+ // temporarily block all signals
+ // this is needed due to a race condition in openpty
+ // and to avoid running signal handlers in the child
+ // before exec* happened
+ sigfillset(&newmask);
+ pthread_sigmask(SIG_SETMASK, &newmask, &oldmask);
+
+ pid = forkpty(&master, nullptr, static_cast(term), static_cast(&winp));
+
+ if (!pid) {
+ // remove all signal handler from child
+ sig_action.sa_handler = SIG_DFL;
+ sig_action.sa_flags = 0;
+ sigemptyset(&sig_action.sa_mask);
+ for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1
+ sigaction(i, &sig_action, NULL);
+ }
+ }
+
+ // reenable signals
+ pthread_sigmask(SIG_SETMASK, &oldmask, NULL);
+
+ switch (pid) {
+ case -1:
+ throw Napi::Error::New(napiEnv, "forkpty(3) failed.");
+ case 0:
+ if (strlen(cwd_.c_str())) {
+ if (chdir(cwd_.c_str()) == -1) {
+ perror("chdir(2) failed.");
+ _exit(1);
+ }
+ }
+
+ if (uid != -1 && gid != -1) {
+ if (setgid(gid) == -1) {
+ perror("setgid(2) failed.");
+ _exit(1);
+ }
+ if (setuid(uid) == -1) {
+ perror("setuid(2) failed.");
+ _exit(1);
+ }
+ }
+
+ {
+ char **old = environ;
+ environ = env;
+ execvp(argv[0], argv);
+ environ = old;
+ perror("execvp(3) failed.");
+ _exit(1);
+ }
+ default:
+ if (pty_nonblock(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
+ }
+ }
+#endif
+
+ Napi::Object obj = Napi::Object::New(napiEnv);
+ obj.Set("fd", Napi::Number::New(napiEnv, master));
+ obj.Set("pid", Napi::Number::New(napiEnv, pid));
+ obj.Set("pty", Napi::String::New(napiEnv, ptsname(master)));
+
+ // Set up process exit callback.
+ Napi::Function cb = info[10].As();
+ SetupExitCallback(napiEnv, cb, pid);
+ return obj;
+}
+
+Napi::Value PtyOpen(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 2 ||
+ !info[0].IsNumber() ||
+ !info[1].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.open(cols, rows)");
+ }
+
+ // size
+ struct winsize winp;
+ winp.ws_col = info[0].As().Int32Value();
+ winp.ws_row = info[1].As().Int32Value();
+ winp.ws_xpixel = 0;
+ winp.ws_ypixel = 0;
+
+ // pty
+ int master, slave;
+ int ret = openpty(&master, &slave, nullptr, NULL, static_cast(&winp));
+
+ if (ret == -1) {
+ throw Napi::Error::New(env, "openpty(3) failed.");
+ }
+
+ if (pty_nonblock(master) == -1) {
+ throw Napi::Error::New(env, "Could not set master fd to nonblocking.");
+ }
+
+ if (pty_nonblock(slave) == -1) {
+ throw Napi::Error::New(env, "Could not set slave fd to nonblocking.");
+ }
+
+ Napi::Object obj = Napi::Object::New(env);
+ obj.Set("master", Napi::Number::New(env, master));
+ obj.Set("slave", Napi::Number::New(env, slave));
+ obj.Set("pty", Napi::String::New(env, ptsname(master)));
+
+ return obj;
+}
+
+Napi::Value PtyResize(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+ if (info.Length() != 3 ||
+ !info[0].IsNumber() ||
+ !info[1].IsNumber() ||
+ !info[2].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.resize(fd, cols, rows)");
+ }
+
+ int fd = info[0].As().Int32Value();
+
+ struct winsize winp;
+ winp.ws_col = info[1].As().Int32Value();
+ winp.ws_row = info[2].As().Int32Value();
+ winp.ws_xpixel = 0;
+ winp.ws_ypixel = 0;
+
+ if (ioctl(fd, TIOCSWINSZ, &winp) == -1) {
+ switch (errno) {
+ case EBADF:
+ throw Napi::Error::New(env, "ioctl(2) failed, EBADF");
+ case EFAULT:
+ throw Napi::Error::New(env, "ioctl(2) failed, EFAULT");
+ case EINVAL:
+ throw Napi::Error::New(env, "ioctl(2) failed, EINVAL");
+ case ENOTTY:
+ throw Napi::Error::New(env, "ioctl(2) failed, ENOTTY");
+ }
+ throw Napi::Error::New(env, "ioctl(2) failed");
+ }
+
+ return env.Undefined();
+}
+
+/**
+ * Foreground Process Name
+ */
+Napi::Value PtyGetProc(const Napi::CallbackInfo& info) {
+ Napi::Env env(info.Env());
+ Napi::HandleScope scope(env);
+
+#if defined(__APPLE__)
+ if (info.Length() != 1 ||
+ !info[0].IsNumber()) {
+ throw Napi::Error::New(env, "Usage: pty.process(pid)");
+ }
+
+ int fd = info[0].As().Int32Value();
+ char *name = pty_getproc(fd);
+#else
+ if (info.Length() != 2 ||
+ !info[0].IsNumber() ||
+ !info[1].IsString()) {
+ throw Napi::Error::New(env, "Usage: pty.process(fd, tty)");
+ }
+
+ int fd = info[0].As().Int32Value();
+
+ std::string tty_ = info[1].As();
+ char *tty = strdup(tty_.c_str());
+ char *name = pty_getproc(fd, tty);
+ free(tty);
+#endif
+
+ if (name == NULL) {
+ return env.Undefined();
+ }
+
+ Napi::String name_ = Napi::String::New(env, name);
+ free(name);
+ return name_;
+}
+
+/**
+ * Nonblocking FD
+ */
+
+static int
+pty_nonblock(int fd) {
+ int flags = fcntl(fd, F_GETFL, 0);
+ if (flags == -1) return -1;
+ return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+}
+
+/**
+ * pty_getproc
+ * Taken from tmux.
+ */
+
+// Taken from: tmux (http://tmux.sourceforge.net/)
+// Copyright (c) 2009 Nicholas Marriott
+// Copyright (c) 2009 Joshua Elsasser
+// Copyright (c) 2009 Todd Carson
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER
+// IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
+// OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+#if defined(__linux__)
+
+static char *
+pty_getproc(int fd, char *tty) {
+ FILE *f;
+ char *path, *buf;
+ size_t len;
+ int ch;
+ pid_t pgrp;
+ int r;
+
+ if ((pgrp = tcgetpgrp(fd)) == -1) {
+ return NULL;
+ }
+
+ r = asprintf(&path, "/proc/%lld/cmdline", (long long)pgrp);
+ if (r == -1 || path == NULL) return NULL;
+
+ if ((f = fopen(path, "r")) == NULL) {
+ free(path);
+ return NULL;
+ }
+
+ free(path);
+
+ len = 0;
+ buf = NULL;
+ while ((ch = fgetc(f)) != EOF) {
+ if (ch == '\0') break;
+ buf = (char *)realloc(buf, len + 2);
+ if (buf == NULL) return NULL;
+ buf[len++] = ch;
+ }
+
+ if (buf != NULL) {
+ buf[len] = '\0';
+ }
+
+ fclose(f);
+ return buf;
+}
+
+#elif defined(__APPLE__)
+
+static char *
+pty_getproc(int fd) {
+ int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, 0 };
+ size_t size;
+ struct kinfo_proc kp;
+
+ if ((mib[3] = tcgetpgrp(fd)) == -1) {
+ return NULL;
+ }
+
+ size = sizeof kp;
+ if (sysctl(mib, 4, &kp, &size, NULL, 0) == -1) {
+ return NULL;
+ }
+
+ if (size != (sizeof kp) || *kp.kp_proc.p_comm == '\0') {
+ return NULL;
+ }
+
+ return strdup(kp.kp_proc.p_comm);
+}
+
+#else
+
+static char *
+pty_getproc(int fd, char *tty) {
+ return NULL;
+}
+
+#endif
+
+#if defined(__APPLE__)
+static void
+pty_posix_spawn(char** argv, char** env,
+ const struct termios *termp,
+ const struct winsize *winp,
+ int* master,
+ pid_t* pid,
+ int* err) {
+ int low_fds[3];
+ size_t count = 0;
+
+ for (; count < 3; count++) {
+ low_fds[count] = posix_openpt(O_RDWR);
+ if (low_fds[count] >= STDERR_FILENO)
+ break;
+ }
+
+ int flags = POSIX_SPAWN_CLOEXEC_DEFAULT |
+ POSIX_SPAWN_SETSIGDEF |
+ POSIX_SPAWN_SETSIGMASK |
+ POSIX_SPAWN_SETSID;
+ *master = posix_openpt(O_RDWR);
+ if (*master == -1) {
+ return;
+ }
+
+ int res = grantpt(*master) || unlockpt(*master);
+ if (res == -1) {
+ return;
+ }
+
+ // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
+ int slave;
+ char slave_pty_name[128];
+ res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
+ if (res == -1) {
+ return;
+ }
+
+ slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
+ if (slave == -1) {
+ return;
+ }
+
+ if (termp) {
+ res = tcsetattr(slave, TCSANOW, termp);
+ if (res == -1) {
+ return;
+ };
+ }
+
+ if (winp) {
+ res = ioctl(slave, TIOCSWINSZ, winp);
+ if (res == -1) {
+ return;
+ }
+ }
+
+ posix_spawn_file_actions_t acts;
+ posix_spawn_file_actions_init(&acts);
+ posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
+ posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
+ posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
+ posix_spawn_file_actions_addclose(&acts, slave);
+ posix_spawn_file_actions_addclose(&acts, *master);
+
+ posix_spawnattr_t attrs;
+ posix_spawnattr_init(&attrs);
+ *err = posix_spawnattr_setflags(&attrs, flags);
+ if (*err != 0) {
+ goto done;
+ }
+
+ sigset_t signal_set;
+ /* Reset all signal the child to their default behavior */
+ sigfillset(&signal_set);
+ *err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
+ if (*err != 0) {
+ goto done;
+ }
+
+ /* Reset the signal mask for all signals */
+ sigemptyset(&signal_set);
+ *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
+ if (*err != 0) {
+ goto done;
+ }
+
+ do
+ *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
+ while (*err == EINTR);
+done:
+ posix_spawn_file_actions_destroy(&acts);
+ posix_spawnattr_destroy(&attrs);
+
+ for (; count > 0; count--) {
+ close(low_fds[count]);
+ }
+}
+#endif
+
+/**
+ * Init
+ */
+
+Napi::Object init(Napi::Env env, Napi::Object exports) {
+ exports.Set("fork", Napi::Function::New(env, PtyFork));
+ exports.Set("open", Napi::Function::New(env, PtyOpen));
+ exports.Set("resize", Napi::Function::New(env, PtyResize));
+ exports.Set("process", Napi::Function::New(env, PtyGetProc));
+ return exports;
+}
+
+NODE_API_MODULE(NODE_GYP_MODULE_NAME, init)
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/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs
index efbafbe9a1b..2d818d5d255 100644
--- a/config/scripts/build-orcad-prebuilds.mjs
+++ b/config/scripts/build-orcad-prebuilds.mjs
@@ -177,10 +177,11 @@ function build() {
copyFileSync(builtBinary, join(slotDir, 'pty.node'))
console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
- // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
+ // Why spawn-helper ships too: on macOS node-pty posix_spawns build/Release/spawn-helper,
// so a slot without it installs cleanly and then fails ENOENT the first time a user
- // opens a terminal. Windows has no spawn-helper.
- if (process.platform !== 'win32') {
+ // opens a terminal. binding.gyp builds the helper only under OS=="mac"; every other
+ // platform forks directly, so demanding one there fails a healthy Linux slot build.
+ if (process.platform === 'darwin') {
const helperSource = join(dirname(builtBinary), 'spawn-helper')
if (!existsSync(helperSource)) {
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs
index 506036ede3a..289c7a957bd 100644
--- a/config/scripts/build-relay.mjs
+++ b/config/scripts/build-relay.mjs
@@ -57,6 +57,13 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
+const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs'
+const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join(
+ ROOT,
+ 'config',
+ 'relay-assets',
+ NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME
+)
// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a
// Windows machine.
const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
@@ -126,6 +133,10 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
+ copyFileSync(
+ NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE,
+ join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)
+ )
stageWindowsProcessTreeAddon(platform, outDir)
await build({
diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs
index 66d2549ea4d..4427c6b7f38 100644
--- a/config/scripts/check-changed-code-quality.mjs
+++ b/config/scripts/check-changed-code-quality.mjs
@@ -4,6 +4,7 @@ import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
+import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
export const OXLINT_SCANS = [
@@ -285,11 +286,12 @@ function isSuppressedDiagnostic(diagnostic, root) {
}
function runOxlintScan(root, scan, files) {
- const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
- const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
+ const { command, prefixArgs } = resolveOxlintInvocation(root)
+ const result = spawnSync(command, [...prefixArgs, ...scan.args, '--format', 'json', ...files], {
cwd: root,
encoding: 'utf8',
- maxBuffer: 128 * 1024 * 1024
+ maxBuffer: 128 * 1024 * 1024,
+ windowsHide: true
})
if (result.error) {
throw result.error
diff --git a/config/scripts/check-react-doctor-changed.mjs b/config/scripts/check-react-doctor-changed.mjs
index f659eefb3d4..743a64eee04 100644
--- a/config/scripts/check-react-doctor-changed.mjs
+++ b/config/scripts/check-react-doctor-changed.mjs
@@ -1,16 +1,30 @@
import { spawnSync } from 'node:child_process'
import process from 'node:process'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
+import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
const requestedBase =
process.argv.slice(2).find((argument) => argument !== '--') ??
process.env.ORCA_CODE_QUALITY_BASE ??
'origin/main'
const base = resolvePullRequestDiffBase(process.cwd(), requestedBase)
-const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+// Why validate rather than trust: `base` arrives from argv or the environment and
+// below it can reach cmd.exe unquoted, because resolvePnpmCliInvocation still
+// falls back to a shell when it cannot find a directly spawnable pnpm. It accepts
+// SHAs, tags, ref paths and the ^ ~ .. suffixes -- not reflog syntax like HEAD@{1},
+// because braces stay out of anything bound for cmd.exe. The error names the base.
+const GIT_REVISION = /^[A-Za-z0-9._/@^~-]+$/
+if (!GIT_REVISION.test(base)) {
+ throw new Error(`Refusing to pass an unsafe diff base to pnpm: ${base}`)
+}
+// Why the shim and not a direct binary: `dlx` fetches react-doctor on demand, so
+// only the pnpm CLI can run it. resolvePnpmCliInvocation prefers whatever
+// npm_execpath exposes -- pnpm 12's own pnpm.exe, spawned with no shell.
+const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
const result = spawnSync(
- pnpm,
+ command,
[
+ ...prefixArgs,
'dlx',
'react-doctor@0.9.1',
'.',
@@ -26,7 +40,7 @@ const result = spawnSync(
'--blocking',
'error'
],
- { stdio: 'inherit' }
+ { stdio: 'inherit', shell, windowsHide: true }
)
if (result.error) {
diff --git a/config/scripts/check-react-doctor-changed.test.mjs b/config/scripts/check-react-doctor-changed.test.mjs
new file mode 100644
index 00000000000..2c5feb90a5d
--- /dev/null
+++ b/config/scripts/check-react-doctor-changed.test.mjs
@@ -0,0 +1,29 @@
+import { spawnSync } from 'node:child_process'
+import path from 'node:path'
+import process from 'node:process'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = path.resolve(import.meta.dirname, '..', '..')
+const script = path.join(repoRoot, 'config', 'scripts', 'check-react-doctor-changed.mjs')
+
+function runWithBase(base) {
+ return spawnSync(process.execPath, [script, base], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ windowsHide: true
+ })
+}
+
+describe('check-react-doctor-changed diff base', () => {
+ // The pnpm invocation can still fall back to a shell, so an unvalidated base
+ // would reach cmd.exe unquoted. Rejection has to happen before the spawn.
+ it.each(['main & calc', 'main | whoami', 'main"x', '%PATH%', 'main $(id)'])(
+ 'refuses %j',
+ (base) => {
+ const result = runWithBase(base)
+
+ expect(result.status).not.toBe(0)
+ expect(result.stderr).toContain('Refusing to pass an unsafe diff base')
+ }
+ )
+})
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 347cae8fcfc..3f055ce222d 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -1,4 +1,4 @@
-import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } 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'
@@ -10,17 +10,8 @@ const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
+const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
-const {
- createPackagedRuntimeNodeModuleResources,
- findAsarEntry,
- prunePackagedNodePty,
- prunePackagedParcelWatcher,
- prunePackagedSherpaOnnx,
- prunePackagedRuntimeTypeAndSourceMapArtifacts,
- prunePackagedZodSources,
- verifyPackagedMainRuntimeDeps
-} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
@@ -280,8 +271,9 @@ describe('electron-builder config', () => {
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
})
- it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => {
- expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb'])
+ 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({
@@ -290,6 +282,33 @@ describe('electron-builder config', () => {
})
})
+ it('retains electron-builder runtime dependencies in deb and rpm packages', () => {
+ for (const target of ['deb', 'rpm']) {
+ const dependencies = electronBuilderConfig[target].depends
+ expect(dependencies).toEqual(
+ expect.arrayContaining(FpmTarget.prototype.getDefaultDepends(target))
+ )
+ expect(new Set(dependencies).size).toBe(dependencies.length)
+ }
+ })
+
+ 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
@@ -367,286 +386,6 @@ describe('electron-builder config', () => {
expect(electronBuilderConfig.npmRebuild).toBe(true)
})
- it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
- try {
- await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
- await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
- await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
-
- const sources = new Map([
- ['out\\main\\index.js', 'const z = require("zod")'],
- ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
- ])
- const asar = {
- listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
- extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
- }
-
- expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('normalizes host-specific asar entry separators', () => {
- expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
- '\\out\\main\\index.js'
- )
- expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
- })
-
- it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
- try {
- const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
- const prebuildsDir = join(nodePtyDir, 'prebuilds')
- const binDir = join(nodePtyDir, 'bin')
- await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
- await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
- await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
- await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
- await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
- await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
- await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
- recursive: true
- })
- await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
-
- prunePackagedNodePty(resourcesDir, 'darwin', 3)
-
- await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
- await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
- await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
- await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
- expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
- 'Unsupported packaged runtime architecture: 4'
- )
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
- for (const [arch, electronArch] of [
- ['x64', 1],
- ['arm64', 3]
- ]) {
- const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
- try {
- const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
- const releaseDir = join(nodePtyDir, 'build', 'Release')
- const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
- await mkdir(releaseDir, { recursive: true })
- await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
- for (const sourceArch of ['x64', 'arm64']) {
- const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
- await mkdir(sourceDir, { recursive: true })
- await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
- await writeFile(
- join(sourceDir, 'OpenConsole.exe'),
- `console payload ${sourceArch}`,
- 'utf8'
- )
- }
-
- prunePackagedNodePty(resourcesDir, 'win32', electronArch)
-
- await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
- `dll payload ${arch}`
- )
- await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
- `console payload ${arch}`
- )
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- }
- })
-
- it('includes external main dependencies in the packaged runtime closure', () => {
- // Why: the main process imports '@parcel/watcher' for filesystem change
- // events; if it is absent from the packaged closure the serve host silently
- // stops propagating file changes to clients (regression guard for #4851).
- const packaged = createPackagedRuntimeNodeModuleResources()
- const packagedTargets = packaged.map((resource) => resource.to)
- expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
- expect(
- packagedTargets.some((target) =>
- target.startsWith(join('node_modules', '@parcel', 'watcher-'))
- )
- ).toBe(true)
- expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
- })
-
- it('prunes non-target @parcel/watcher architecture subpackages', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
- try {
- const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
- await mkdir(join(parcelDir, 'watcher'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
-
- prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
-
- await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
- 'watcher',
- 'watcher-linux-arm64-glibc'
- ])
- expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
- 'Unsupported packaged runtime architecture: universal'
- )
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
- try {
- const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
- await mkdir(join(parcelDir, 'watcher'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
- await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
- // A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
- await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
-
- prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
-
- await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
- 'transformer-js',
- 'watcher',
- 'watcher-linux-x64-glibc'
- ])
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
- try {
- const packageDir = join(resourcesDir, 'node_modules', 'example-package')
- await mkdir(join(packageDir, 'dist'), { recursive: true })
- await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
- await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
- await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
- await writeFile(join(packageDir, 'dist', 'index.d.mts'), 'export type Value = string', 'utf8')
- await writeFile(join(packageDir, 'dist', 'index.d.cts.map'), '{}', 'utf8')
- await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
-
- prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
-
- await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
- try {
- const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
- await mkdir(packageDir, { recursive: true })
- await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
- await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
- await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
-
- prunePackagedSherpaOnnx(resourcesDir, 'darwin')
-
- await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
- 'libonnxruntime.1.23.2.dylib',
- 'sherpa-onnx.node'
- ])
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('prunes zod TypeScript sources from packaged runtime resources', async () => {
- const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
- try {
- const packageDir = join(resourcesDir, 'node_modules', 'zod')
- await mkdir(join(packageDir, 'src'), { recursive: true })
- await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
- await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
-
- prunePackagedZodSources(resourcesDir)
-
- await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
- } finally {
- await rm(resourcesDir, { recursive: true, force: true })
- }
- })
-
- it('fails when the packaged resources directory is missing', async () => {
- const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
- try {
- await expect(
- electronBuilderConfig.afterPack({
- appOutDir: root,
- electronPlatformName: 'win32'
- })
- ).rejects.toThrow(/Missing packaged resources directory/)
- } finally {
- await rm(root, { recursive: true, force: true })
- }
- })
-
- it.skipIf(process.platform === 'win32')(
- 'marks packaged Unix CLI launchers executable',
- async () => {
- const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
- try {
- const resourcesDir = join(root, 'linux-unpacked', 'resources')
- const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
- await mkdir(join(resourcesDir, 'bin'), { recursive: true })
- await cp(
- join(process.cwd(), 'resources', 'plugins', 'launch'),
- join(resourcesDir, 'plugins', 'launch'),
- { recursive: true }
- )
- await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
- // Why: afterPack now fails hard when the unpacked daemon entry is
- // missing, so the fixture must carry one like a real package layout.
- const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
- await mkdir(unpackedMainDir, { recursive: true })
- await writeFile(
- join(unpackedMainDir, 'daemon-entry.js'),
- 'console.error("Usage: daemon-entry "); process.exit(1)\n',
- 'utf8'
- )
- const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
- await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
- await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
- await writeFile(
- join(unpackedCliDir, 'index.js'),
- [
- 'const args = process.argv.slice(2)',
- "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
- "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
- 'else console.log(JSON.stringify({ executed: false }))'
- ].join('\n'),
- 'utf8'
- )
- await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
-
- await electronBuilderConfig.afterPack({
- appOutDir: join(root, 'linux-unpacked'),
- electronPlatformName: 'linux',
- arch: 1
- })
-
- expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
- } finally {
- await rm(root, { recursive: true, force: true })
- }
- }
- )
-
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
@@ -680,5 +419,17 @@ describe('electron-builder config', () => {
expect(source).toContain(`value === '${target}'`)
}
})
+
+ it('keeps the pinned FpmTarget overwrite for configured deb and rpm artifacts', async () => {
+ const source = await readFile(
+ require.resolve('app-builder-lib/out/targets/FpmTarget'),
+ 'utf8'
+ )
+
+ expect(source).toContain('path.join(resourceDir, "package-type"), target')
+ for (const target of RECOVERABLE_TARGETS) {
+ expect(electronBuilderConfig[target]).toBeDefined()
+ }
+ })
})
})
diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs
new file mode 100644
index 00000000000..d2407776fa7
--- /dev/null
+++ b/config/scripts/electron-builder-runtime-resources.test.mjs
@@ -0,0 +1,308 @@
+import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, 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 electronBuilderConfig = require('../electron-builder.config.cjs')
+const {
+ createPackagedRuntimeNodeModuleResources,
+ findAsarEntry,
+ prunePackagedNodePty,
+ prunePackagedParcelWatcher,
+ prunePackagedSherpaOnnx,
+ prunePackagedRuntimeTypeAndSourceMapArtifacts,
+ prunePackagedZodSources,
+ verifyPackagedMainRuntimeDeps
+} = require('../packaged-runtime-node-modules.cjs')
+
+describe('packaged runtime resources', () => {
+ it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
+ try {
+ await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
+ await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
+ await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
+
+ const sources = new Map([
+ ['out\\main\\index.js', 'const z = require("zod")'],
+ ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
+ ])
+ const asar = {
+ listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
+ extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
+ }
+
+ expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('normalizes host-specific asar entry separators', () => {
+ expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
+ '\\out\\main\\index.js'
+ )
+ expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
+ })
+
+ it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
+ try {
+ const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
+ const prebuildsDir = join(nodePtyDir, 'prebuilds')
+ const binDir = join(nodePtyDir, 'bin')
+ await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
+ await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
+ await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
+ await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
+ await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
+ await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
+ await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
+ recursive: true
+ })
+ await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
+
+ prunePackagedNodePty(resourcesDir, 'darwin', 3)
+
+ await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
+ await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
+ await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
+ await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
+ expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
+ 'Unsupported packaged runtime architecture: 4'
+ )
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
+ for (const [arch, electronArch] of [
+ ['x64', 1],
+ ['arm64', 3]
+ ]) {
+ const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
+ try {
+ const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
+ const releaseDir = join(nodePtyDir, 'build', 'Release')
+ const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
+ await mkdir(releaseDir, { recursive: true })
+ await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
+ for (const sourceArch of ['x64', 'arm64']) {
+ const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
+ await mkdir(sourceDir, { recursive: true })
+ await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
+ await writeFile(
+ join(sourceDir, 'OpenConsole.exe'),
+ `console payload ${sourceArch}`,
+ 'utf8'
+ )
+ }
+
+ prunePackagedNodePty(resourcesDir, 'win32', electronArch)
+
+ await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
+ `dll payload ${arch}`
+ )
+ await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
+ `console payload ${arch}`
+ )
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ }
+ })
+
+ it('includes external main dependencies in the packaged runtime closure', () => {
+ // Why: the main process imports '@parcel/watcher' for filesystem change
+ // events; if it is absent from the packaged closure the serve host silently
+ // stops propagating file changes to clients (regression guard for #4851).
+ const packaged = createPackagedRuntimeNodeModuleResources()
+ const packagedTargets = packaged.map((resource) => resource.to)
+ expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
+ expect(
+ packagedTargets.some((target) =>
+ target.startsWith(join('node_modules', '@parcel', 'watcher-'))
+ )
+ ).toBe(true)
+ expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
+ })
+
+ it('prunes non-target @parcel/watcher architecture subpackages', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
+ try {
+ const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
+ await mkdir(join(parcelDir, 'watcher'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
+
+ prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
+
+ await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
+ 'watcher',
+ 'watcher-linux-arm64-glibc'
+ ])
+ expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
+ 'Unsupported packaged runtime architecture: universal'
+ )
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
+ try {
+ const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
+ await mkdir(join(parcelDir, 'watcher'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
+ await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
+ // A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
+ await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
+
+ prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
+
+ await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
+ 'transformer-js',
+ 'watcher',
+ 'watcher-linux-x64-glibc'
+ ])
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
+ try {
+ const packageDir = join(resourcesDir, 'node_modules', 'example-package')
+ await mkdir(join(packageDir, 'dist'), { recursive: true })
+ await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
+ await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
+ await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
+ await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
+
+ prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
+
+ await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
+ try {
+ const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
+ await mkdir(packageDir, { recursive: true })
+ await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
+ await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
+ await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
+
+ prunePackagedSherpaOnnx(resourcesDir, 'darwin')
+
+ await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
+ 'libonnxruntime.1.23.2.dylib',
+ 'sherpa-onnx.node'
+ ])
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('prunes zod TypeScript sources from packaged runtime resources', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
+ try {
+ const packageDir = join(resourcesDir, 'node_modules', 'zod')
+ await mkdir(join(packageDir, 'src'), { recursive: true })
+ await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
+ await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
+
+ prunePackagedZodSources(resourcesDir)
+
+ await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ it('fails when the packaged resources directory is missing', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
+ try {
+ await expect(
+ electronBuilderConfig.afterPack({
+ appOutDir: root,
+ electronPlatformName: 'win32'
+ })
+ ).rejects.toThrow(/Missing packaged resources directory/)
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'marks packaged Unix CLI launchers executable',
+ async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
+ try {
+ const resourcesDir = join(root, 'linux-unpacked', 'resources')
+ const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
+ await mkdir(join(resourcesDir, 'bin'), { recursive: true })
+ await cp(
+ join(process.cwd(), 'resources', 'plugins', 'launch'),
+ join(resourcesDir, 'plugins', 'launch'),
+ { recursive: true }
+ )
+ await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
+ // Why: afterPack now fails hard when the unpacked daemon entry is
+ // missing, so the fixture must carry one like a real package layout.
+ const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
+ await mkdir(unpackedMainDir, { recursive: true })
+ await writeFile(
+ join(unpackedMainDir, 'daemon-entry.js'),
+ 'console.error("Usage: daemon-entry "); process.exit(1)\n',
+ 'utf8'
+ )
+ await writeFile(
+ join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'),
+ `${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`,
+ 'utf8'
+ )
+ const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
+ await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
+ await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
+ await writeFile(
+ join(unpackedCliDir, 'index.js'),
+ [
+ 'const args = process.argv.slice(2)',
+ "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
+ "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
+ 'else console.log(JSON.stringify({ executed: false }))'
+ ].join('\n'),
+ 'utf8'
+ )
+ await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
+
+ await electronBuilderConfig.afterPack({
+ appOutDir: join(root, 'linux-unpacked'),
+ electronPlatformName: 'linux',
+ arch: 1,
+ packager: { appInfo: { version: '9.9.9' } }
+ })
+
+ expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
+ await expect(
+ readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8')
+ ).resolves.toContain('"version": "9.9.9"')
+ await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage')
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ }
+ )
+})
diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs
index 6590596e41c..90a3f73c77d 100644
--- a/config/scripts/headless-serve-shutdown-workflow.test.mjs
+++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs
@@ -5,6 +5,17 @@ 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'
+)
+const headlessLinuxProse = headlessLinuxGuide.replace(/\s+/g, ' ')
function readSystemdUnitBlocks(doc, unitName) {
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -40,16 +51,112 @@ describe('headless serve shutdown PR gate', () => {
).toThrow('Missing closing code fence for orca-serve.service')
})
- it('packages an x64 AppImage before running the Docker signal oracle', () => {
+ it('packages Linux artifacts before running the Docker signal oracle', () => {
const steps = workflow.jobs.package.steps
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
+ const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads')
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
+ const launcherShutdownStep = steps.find(
+ (step) => step.name === 'Verify extracted launcher serve signal shutdown'
+ )
+ const appImageShutdownStep = steps.find(
+ (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown'
+ )
- expect(packageStep.run).toContain('--linux AppImage --x64 --publish never')
+ expect(workflow.jobs.package['timeout-minutes']).toBe(90)
+ expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never')
+ expect(markerStep.run).toContain('dpkg-deb --fsys-tarfile')
+ expect(markerStep.run).toContain('rpm2cpio')
+ expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep))
expect(shutdownStep.run).toBe(
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
)
+ expect(launcherShutdownStep.run).toContain(
+ 'node config/scripts/run-headless-serve-shutdown-docker.mjs'
+ )
+ expect(launcherShutdownStep.run).toContain('--entrypoint launcher')
+ expect(appImageShutdownStep.run).toContain('--entrypoint appimage')
+ expect(appImageShutdownStep.run).toContain('--signal-target serving-electron')
+ expect(appImageShutdownStep.run).toContain('--int-delivery pid')
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
+ expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep))
+ expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep))
+ expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep))
+ })
+
+ it('keeps readiness polling finite and leak-free', () => {
+ expect(signalCase).toContain('read_ready_line()')
+ expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'")
+ expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}')
+ expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))')
+ expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do')
+ expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break')
+ expect(signalCase).toContain(
+ "jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F"
+ )
+ expect(signalCase).not.toContain('tail --pid=')
+ })
+
+ it('gives owned shutdown state a bounded cleanup grace', () => {
+ expect(signalCase).toContain('for shutdown_poll in {0..50}; do')
+ expect(signalCase).toContain('[[ -z "$listener_after" && -z "$owned_residue" ]]')
+ expect(signalCase).toContain('((${#survivors[@]} == 0))')
+ expect(signalCase).toContain('((shutdown_poll < 50)) && sleep 0.1')
+ })
+
+ it('checks that a serving-electron signal target owns the ready socket', () => {
+ const ssRecord =
+ 'LISTEN 0 128 127.0.0.1:41235 0.0.0.0:* users:(("orca-ide",pid=23,fd=7),("orca-ide",pid=25,fd=8))'
+ expect([...ssRecord.matchAll(/pid=([0-9]+)/g)].map((match) => match[1])).toEqual(['23', '25'])
+ expect(signalCase).toContain(
+ 'listener_before_pids=$(grep -oE \'pid=[0-9]+\' <<<"$listener_before" | cut -d= -f2 || true)'
+ )
+ expect(signalCase).toContain('signal_target_pid=$(head -n1 <<<"$listener_before_pids")')
+ 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', () => {
@@ -63,4 +170,37 @@ describe('headless serve shutdown PR gate', () => {
expect(managedXvfbUnits).toHaveLength(1)
expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m)
})
+
+ it('distinguishes persisted state from live work during a service restart', () => {
+ expect(headlessLinuxProse).toContain(
+ 'Every `systemctl stop` or `restart` therefore ends live terminals and agent processes'
+ )
+ expect(headlessLinuxProse).toContain(
+ 'These guarantees do not preserve live processes. The service restart kills every terminal and agent in its cgroup'
+ )
+ expect(headlessLinuxProse).toContain(
+ 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, failed request or lost connection is `unverifiable`'
+ )
+ expect(headlessLinuxGuide).toContain(
+ 'sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json'
+ )
+ expect(headlessLinuxGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json')
+ expect(headlessLinuxGuide).not.toContain('Two facts make this safe and predictable')
+ })
+
+ it('uses the registered CLI name from ordinary Linux shells', () => {
+ const commandRule =
+ 'The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing the GNOME Orca screen reader.'
+ const substitutionRule =
+ "From an ordinary shell outside that service user's managed environment, substitute `orca-ide` for `orca` in commands below."
+ const censusCommand = '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`'
+
+ expect(headlessLinuxProse).toContain(commandRule)
+ expect(headlessLinuxProse).toContain(substitutionRule)
+ expect(headlessLinuxProse).toContain(censusCommand)
+ expect(headlessLinuxGuide).toContain('best-effort dispatcher at `$HOME/.local/bin/orca`')
+ expect(headlessLinuxProse.indexOf(substitutionRule)).toBeLessThan(
+ headlessLinuxProse.indexOf(censusCommand)
+ )
+ })
})
diff --git a/config/scripts/lint-react-doctor-changed.mjs b/config/scripts/lint-react-doctor-changed.mjs
index 971c28800ef..0f294f481f2 100644
--- a/config/scripts/lint-react-doctor-changed.mjs
+++ b/config/scripts/lint-react-doctor-changed.mjs
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
+import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
@@ -31,11 +32,11 @@ if (lintTargets.length === 0) {
process.exit(0)
}
-const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+const { command, prefixArgs } = resolveOxlintInvocation()
const result = spawnSync(
- pnpm,
- ['exec', 'oxlint', '--config', 'config/oxlint-react-doctor.json', ...lintTargets],
- { stdio: 'inherit' }
+ command,
+ [...prefixArgs, '--config', 'config/oxlint-react-doctor.json', ...lintTargets],
+ { stdio: 'inherit', windowsHide: true }
)
if (result.error) {
diff --git a/config/scripts/linux-package-maintainer-scripts.test.mjs b/config/scripts/linux-package-maintainer-scripts.test.mjs
new file mode 100644
index 00000000000..f315f2fd2ee
--- /dev/null
+++ b/config/scripts/linux-package-maintainer-scripts.test.mjs
@@ -0,0 +1,18 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+describe('Linux package maintainer scripts', () => {
+ it('keeps upgrades from removing the installed CLI', () => {
+ const script = readFileSync(
+ new URL('../../resources/linux/packaging/after-remove.sh', import.meta.url),
+ 'utf8'
+ )
+ const unlinkStart = script.indexOf('link="/usr/bin/orca-ide"')
+ const upgradeGuard = script.slice(0, unlinkStart)
+
+ expect(unlinkStart).toBeGreaterThan(-1)
+ expect(upgradeGuard).toContain('case "${1-}" in')
+ expect(upgradeGuard).toContain('0 | remove | purge) ;;')
+ expect(upgradeGuard).toContain('*) exit 0 ;;')
+ })
+})
diff --git a/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs b/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs
index bed29fe18b4..8c5c403ff74 100644
--- a/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs
+++ b/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs
@@ -3,11 +3,10 @@ import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
+import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs')
-const oxlintPath = path.resolve(
- process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint'
-)
+const oxlint = resolveOxlintInvocation()
function lintSource(source) {
const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-'))
@@ -22,9 +21,11 @@ function lintSource(source) {
rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' }
})
)
- const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], {
- encoding: 'utf8'
- })
+ const result = spawnSync(
+ oxlint.command,
+ [...oxlint.prefixArgs, '--config', configPath, '--format', 'json', sourcePath],
+ { encoding: 'utf8', windowsHide: true }
+ )
if (result.error) {
throw result.error
}
diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs
new file mode 100644
index 00000000000..16013cbf0a3
--- /dev/null
+++ b/config/scripts/node-pty-master-cloexec-patch.test.mjs
@@ -0,0 +1,229 @@
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { join, resolve } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const {
+ SKIP_MARKER_FILENAME,
+ applyNodePtyMasterCloexecPatch,
+ assertPatchedNodePtyMasterCloexecSource,
+ patchNodePtyMasterCloexecSource,
+ revertNodePtyMasterCloexecSource
+} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs')
+
+// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its
+// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous.
+const STOCK_SOURCE = readFileSync(
+ resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'),
+ 'utf8'
+)
+const projectDir = resolve(import.meta.dirname, '..', '..')
+const cleanupDirs = []
+
+afterEach(() => {
+ for (const dir of cleanupDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+describe('SSH relay node-pty pty-master close-on-exec patch', () => {
+ it('adds the forkpty close-on-exec call and reverts to the published bytes', () => {
+ const fixture = writeRelayFixture()
+
+ expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true)
+ const patched = readFileSync(fixture.sourcePath, 'utf8')
+ expect(patched).toContain('pty_cloexec(int fd)')
+ expect(patched).toContain('if (pty_cloexec(master) == -1)')
+ expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow()
+
+ expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false)
+ expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched)
+
+ expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true)
+ expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
+ })
+
+ it('refuses a different node-pty version or an unrecognized source', () => {
+ const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' })
+ expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0')
+
+ const drifted = writeRelayFixture({
+ source: `${STOCK_SOURCE}\n// drift\n`
+ })
+ expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty')
+
+ const tampered = writeRelayFixture()
+ patchNodePtyMasterCloexecSource(tampered.root)
+ writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`)
+ expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed')
+ })
+
+ it('keeps the rebuilt addon once a later child no longer inherits the master', () => {
+ const fixture = writeRelayFixture()
+ const calls = []
+
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => {
+ calls.push('rebuild')
+ writeBuild(fixture, 'patched-build')
+ },
+ verify: () => 'isolated'
+ })
+
+ expect(status).toBe('patched')
+ expect(calls).toEqual(['rebuild'])
+ expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
+ expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE)
+ expect(existsSync(fixture.backupDir)).toBe(false)
+ expect(existsSync(fixture.skipMarkerPath)).toBe(false)
+ })
+
+ it('keeps a rebuilt addon whose flag /proc could not confirm', () => {
+ const fixture = writeRelayFixture()
+
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => writeBuild(fixture, 'patched-build'),
+ verify: () => 'unverified'
+ })
+
+ expect(status).toBe('patched-unverified')
+ expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
+ })
+
+ it('restores the working build when the compile fails, and never retries it', () => {
+ const fixture = writeRelayFixture()
+ const calls = []
+
+ const failed = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => {
+ calls.push('rebuild')
+ throw new Error('npm rebuild node-pty exited 1: no C++ toolchain')
+ },
+ verify: () => 'isolated'
+ })
+
+ expect(failed).toContain('failed:')
+ expect(failed).toContain('no C++ toolchain')
+ expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
+ expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
+ expect(existsSync(fixture.backupDir)).toBe(false)
+ expect(existsSync(fixture.skipMarkerPath)).toBe(true)
+
+ // Bounded, not backed off: a relay directory gets one compile attempt, ever.
+ const again = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => calls.push('rebuild'),
+ verify: () => 'isolated'
+ })
+ expect(again).toBe('skipped:earlier-attempt-failed')
+ expect(calls).toEqual(['rebuild'])
+ })
+
+ it('restores the working build when the rebuilt addon still leaks the master', () => {
+ const fixture = writeRelayFixture()
+
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => writeBuild(fixture, 'still-leaky-build'),
+ verify: () => {
+ throw new Error('rebuilt node-pty still leaks the pty master into later children')
+ }
+ })
+
+ expect(status).toContain('still leaks')
+ expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
+ expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
+ })
+
+ it('never compiles on a platform that does not leak', () => {
+ for (const platform of ['darwin', 'win32']) {
+ const fixture = writeRelayFixture()
+ const calls = []
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform,
+ rebuild: () => calls.push('rebuild'),
+ verify: () => 'isolated'
+ })
+ expect(status).toBe('skipped:not-linux')
+ expect(calls).toEqual([])
+ expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
+ }
+ })
+
+ it('leaves an already patched install alone', () => {
+ const fixture = writeRelayFixture()
+ patchNodePtyMasterCloexecSource(fixture.root)
+ const calls = []
+
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => calls.push('rebuild'),
+ verify: () => 'isolated'
+ })
+
+ expect(status).toBe('already-patched')
+ expect(calls).toEqual([])
+ })
+
+ it('will not rebuild an install that has no compiled addon to fall back on', () => {
+ const fixture = writeRelayFixture({ build: false })
+ const calls = []
+
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => calls.push('rebuild'),
+ verify: () => 'isolated'
+ })
+
+ expect(status).toBe('skipped:no-compiled-build')
+ expect(calls).toEqual([])
+ expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
+ })
+
+ it('discards a backup stranded by an interrupted rebuild', () => {
+ const fixture = writeRelayFixture()
+ mkdirSync(fixture.backupDir, { recursive: true })
+ writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build')
+
+ const status = applyNodePtyMasterCloexecPatch(fixture.root, {
+ platform: 'linux',
+ rebuild: () => writeBuild(fixture, 'patched-build'),
+ verify: () => 'isolated'
+ })
+
+ expect(status).toBe('patched')
+ expect(existsSync(fixture.backupDir)).toBe(false)
+ expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
+ })
+})
+
+function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) {
+ const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-'))
+ cleanupDirs.push(root)
+ const nodePtyDir = join(root, 'node_modules', 'node-pty')
+ const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc')
+ const buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node')
+ mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
+ writeFileSync(sourcePath, source)
+ const fixture = {
+ root,
+ sourcePath,
+ buildPath,
+ backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'),
+ skipMarkerPath: join(root, SKIP_MARKER_FILENAME)
+ }
+ if (build) {
+ writeBuild(fixture, 'stock-build')
+ }
+ return fixture
+}
+
+function writeBuild(fixture, contents) {
+ mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true })
+ writeFileSync(fixture.buildPath, contents)
+}
diff --git a/config/scripts/orcad-operations-restart-safety.test.mjs b/config/scripts/orcad-operations-restart-safety.test.mjs
new file mode 100644
index 00000000000..60f9cb05524
--- /dev/null
+++ b/config/scripts/orcad-operations-restart-safety.test.mjs
@@ -0,0 +1,42 @@
+import { readFileSync } from 'node:fs'
+
+import { describe, expect, it } from 'vitest'
+
+const operationsGuide = readFileSync('docs/reference/orcad-operations.md', 'utf8')
+const operationsProse = operationsGuide.replace(/\s+/g, ' ')
+
+describe('orcad operations restart safety', () => {
+ it('distinguishes PID-scoped preservation from systemd cgroup teardown', () => {
+ expect(operationsProse).toContain(
+ 'This makes a PID-scoped update, rollback or restart non-destructive to live work'
+ )
+ expect(operationsProse).toContain(
+ 'The successor adopts the current endpoint and routes supported previous protocol versions through legacy adapters'
+ )
+ expect(operationsProse).toContain('`KillMode=mixed` does **not** preserve them')
+ expect(operationsProse).toContain(
+ '`KillMode=process` leaves service-owned processes unmanaged and is not a supported preservation mechanism'
+ )
+ })
+
+ it('fails closed before cgroup-wide maintenance', () => {
+ expect(operationsProse).toContain(
+ 'A safe empty census is untruncated, has an explicit `hostScope`, covers every execution host affected by the stop, and lists no terminals on those hosts'
+ )
+ expect(operationsProse).toContain(
+ "Every `omittedHostIds` entry must be explicitly accounted for outside the target service's execution boundary"
+ )
+ expect(operationsProse).toContain(
+ '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`'
+ )
+ expect(operationsGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json')
+ expect(operationsProse).toContain(
+ 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, truncation, a failed request or lost contact makes the result `unverifiable`'
+ )
+ expect(operationsProse).toContain('Orca does not yet provide an atomic census-and-stop fence')
+ })
+
+ it('does not refer to the unavailable shipping design', () => {
+ expect(operationsGuide).not.toContain('docs/design/shipping-orcad.html')
+ })
+})
diff --git a/config/scripts/oxlint-cli-invocation.mjs b/config/scripts/oxlint-cli-invocation.mjs
new file mode 100644
index 00000000000..605aa33c686
--- /dev/null
+++ b/config/scripts/oxlint-cli-invocation.mjs
@@ -0,0 +1,23 @@
+import { createRequire } from 'node:module'
+import path from 'node:path'
+import process from 'node:process'
+
+// Why not `pnpm exec oxlint` / `node_modules/.bin/oxlint.cmd`: both land on a
+// Windows .cmd shim, and Node >= 20 refuses to spawn one without `shell: true`
+// (the CVE-2024-27980 mitigation), so every lint gate died with EINVAL before
+// linting anything. Oxlint's bin is a plain Node script, so run it under this
+// process's own node — no shim, no shell, no quoting question.
+export function resolveOxlintInvocation(root = process.cwd()) {
+ const requireFromRoot = createRequire(path.join(root, 'package.json'))
+ // Oxlint's "exports" hides ./bin, so read the manifest and walk to its bin entry.
+ const manifestPath = requireFromRoot.resolve('oxlint/package.json')
+ const binField = requireFromRoot('oxlint/package.json').bin
+ const binEntry = typeof binField === 'string' ? binField : binField?.oxlint
+ if (!binEntry) {
+ throw new Error('oxlint package.json declares no "oxlint" bin entry.')
+ }
+ return {
+ command: process.execPath,
+ prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)]
+ }
+}
diff --git a/config/scripts/oxlint-cli-invocation.test.mjs b/config/scripts/oxlint-cli-invocation.test.mjs
new file mode 100644
index 00000000000..7a681a825d9
--- /dev/null
+++ b/config/scripts/oxlint-cli-invocation.test.mjs
@@ -0,0 +1,33 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { describe, expect, it } from 'vitest'
+import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
+
+const repoRoot = path.resolve(import.meta.dirname, '..', '..')
+
+describe('resolveOxlintInvocation', () => {
+ it('runs oxlint under this process node, never through a shim', () => {
+ const { command, prefixArgs } = resolveOxlintInvocation(repoRoot)
+
+ expect(command).toBe(process.execPath)
+ expect(prefixArgs).toHaveLength(1)
+ // The EINVAL that killed the changed-code gate came from spawning a .cmd.
+ expect(prefixArgs[0]).not.toMatch(/\.(cmd|bat)$/i)
+ expect(existsSync(prefixArgs[0])).toBe(true)
+ })
+
+ it('spawns without a shell and produces Oxlint JSON', () => {
+ const { command, prefixArgs } = resolveOxlintInvocation(repoRoot)
+ const result = spawnSync(
+ command,
+ [...prefixArgs, '--help'],
+ // shell:false is the point: the shim form throws EINVAL here on Windows.
+ { cwd: repoRoot, encoding: 'utf8', shell: false, windowsHide: true }
+ )
+
+ expect(result.error).toBeUndefined()
+ expect(result.stdout).toContain('oxlint')
+ })
+})
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index 9f62802c84f..950d5ed258a 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -655,6 +655,8 @@ describe('Electron runtime package contract', () => {
expect(releaseWindowsRunStep.run).toContain(
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
+ expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden')
+ expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden')
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
expect(
releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort()
diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index c296ad9e893..67d4f565afa 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -167,6 +167,7 @@ const SHARED_PACKAGE_PREFIXES = [
'config/scripts/smoke-packaged',
'config/scripts/install-electron-package-binary',
'config/scripts/verify-packaged',
+ 'config/scripts/verify-skills-cli-runtime',
'config/scripts/verify-linux-glibc',
'config/scripts/run-electron-vite',
'skills/',
@@ -180,6 +181,12 @@ 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/',
'config/scripts/run-headless-serve'
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index f9411eed956..9a1c9e649b6 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -181,6 +181,28 @@ describe('per-job path classification', () => {
expectClassification(['native/computer-use-macos/Package.swift'], {})
})
+ it('runs Linux packaging when an artifact contract changes', () => {
+ 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 })
+ }
+ })
+
+ it('runs both package jobs when the shared skills runtime verifier changes', () => {
+ expectClassification(['config/scripts/verify-skills-cli-runtime.cjs'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
it('runs shell contracts when live-shell inputs change', () => {
expectClassification(['src/main/daemon/shell-ready.ts'], {
shell_contracts: true,
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 7cf2b4e11b4..ceac6b8cc6e 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -114,6 +114,7 @@ describe('PR E2E gate contract', () => {
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
'${{ steps.filter.outputs.test_files }}'
)
+ expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
})
@@ -266,6 +267,7 @@ describe('PR E2E gate contract', () => {
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs
index 1aed38db92e..d81f4c040fe 100644
--- a/config/scripts/pr-e2e-source-routing.mjs
+++ b/config/scripts/pr-e2e-source-routing.mjs
@@ -27,6 +27,7 @@ export const PR_E2E_SOURCE_ROUTES = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index c69b04d663f..92d4fe4c26b 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -102,8 +102,13 @@ describe('PR workflow parallelism', () => {
.split(/\s+/)
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
.filter((token) => !token.startsWith('-'))
- const jobsInstallingPackages = Object.entries(workflow.jobs)
- .filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0))
+ const requiredShells = ['zsh', 'fish']
+ const jobsInstallingShells = Object.entries(workflow.jobs)
+ .filter(([, job]) =>
+ (job.steps ?? []).some((step) =>
+ aptPackages(step).some((packageName) => requiredShells.includes(packageName))
+ )
+ )
.map(([name]) => name)
expect(shellStep).toBeDefined()
@@ -111,11 +116,11 @@ describe('PR workflow parallelism', () => {
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
- expect(jobsInstallingPackages).toEqual(['shell_contracts'])
+ expect(jobsInstallingShells).toEqual(['shell_contracts'])
// Why each shell is asserted: the live tests skip themselves when the binary is
// missing, so a dropped package silently empties this lane instead of failing it.
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
- for (const shell of ['zsh', 'fish']) {
+ for (const shell of requiredShells) {
expect(shellPackages).toContain(shell)
}
expect(shellInstall.with['native-runtime']).toBe('node')
diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs
index f3852624854..184713c41a0 100755
--- a/config/scripts/run-headless-serve-shutdown-docker.mjs
+++ b/config/scripts/run-headless-serve-shutdown-docker.mjs
@@ -17,7 +17,7 @@ if (!appImageArg) {
if (!['app', 'serving-electron'].includes(signalTarget)) {
fail(`Unsupported --signal-target: ${signalTarget}`)
}
-if (!['app', 'launcher'].includes(entrypoint)) {
+if (!['app', 'appimage', 'launcher'].includes(entrypoint)) {
fail(`Unsupported --entrypoint: ${entrypoint}`)
}
if (!['pid', 'foreground-process-group'].includes(intDelivery)) {
@@ -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(' && ')
])
@@ -108,6 +122,8 @@ try {
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
+ `${appImage}:/input/orca.AppImage:ro`,
+ '-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
@@ -129,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/run-linux-cli-launch-contract-docker.mjs b/config/scripts/run-linux-cli-launch-contract-docker.mjs
new file mode 100755
index 00000000000..901e0877e85
--- /dev/null
+++ b/config/scripts/run-linux-cli-launch-contract-docker.mjs
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+// Exercise packaged CLI paths under the hostile Linux conditions from #11609/#12530/#13719/#14229.
+import { execFileSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const commandArgs = process.argv.slice(2)
+const appImageArg = valueAfter('--appimage')
+const appImage = appImageArg ? resolve(appImageArg) : null
+const platform = valueAfter('--platform')
+const dockerPlatformArgs = platform ? ['--platform', platform] : []
+
+const suffix = `${process.pid}-${Date.now()}`
+const artifactVolume = `orca-cli-contract-artifact-${suffix}`
+const tagArchitecture = platform?.split('/')[1] ?? process.arch
+const tag = `orca-cli-launch-contract:ubuntu-24.04-${tagArchitecture}-${suffix}`
+const base = 'ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90'
+const containers = new Set()
+let artifactVolumeCreated = false
+const CASE_TIMEOUT_MS = 90_000
+const BUILD_TIMEOUT_MS = 10 * 60_000
+const STAGING_TIMEOUT_MS = 5 * 60_000
+const DOCKER_TIMEOUT_MS = 2 * 60_000
+const CLEANUP_TIMEOUT_MS = 30_000
+
+// Exact statuses reject silent no-op launches as well as crashes.
+const CASES = [
+ {
+ name: 'nofuse-userns-bundled-help',
+ expectStatus: 0,
+ expectOutput: 'Usage: orca ',
+ why: 'The bundled launcher must run with no FUSE, no display, and userns restricted (#11609, #12530).'
+ },
+ {
+ name: 'nofuse-userns-bundled-version',
+ expectStatus: 0,
+ expectOutput: /^\d+\.\d+\.\d+/m,
+ why: 'A deployment must be able to read the installed version without a display (#13719).'
+ },
+ {
+ name: 'nofuse-userns-bundled-status',
+ // No runtime is running; the CLI must report that itself.
+ expectStatus: 1,
+ expectOutput: 'appRunning',
+ why: 'A command that needs the runtime must report its absence, not abort.'
+ },
+ {
+ name: 'nofuse-userns-bundled-skills',
+ expectStatus: 0,
+ // Why: the rendered help header, not a bare 'skills' — the case name contains that word.
+ expectOutput: 'Usage: orca skills',
+ why: 'skills is a pure-text command that must never need Chromium (#14229).'
+ },
+ {
+ name: 'nofuse-userns-bundled-worktree',
+ expectStatus: 1,
+ expectOutput: "Orca is not running. Run 'orca open' first.",
+ why: 'A runtime-dependent command must report the missing runtime, not abort.'
+ },
+ {
+ name: 'nofuse-nosandbox-direct-binary-skills',
+ expectStatus: 0,
+ expectOutput: 'Usage: orca skills',
+ why: 'A direct binary launch that reaches JavaScript must run the command, not boot a GUI (#14229).'
+ },
+ {
+ name: 'nofuse-nosandbox-direct-binary-gui',
+ // A missing display is an expected diagnosis, not a crash.
+ expectStatus: 1,
+ expectOutput: 'needs a usable display server',
+ why: 'A desktop launch with no display must diagnose it instead of dying in uv_close (#13719).'
+ },
+ {
+ name: 'stale-display-nosandbox-direct-binary-gui',
+ expectStatus: 1,
+ expectOutput: 'needs a usable display server',
+ why: 'A stale DISPLAY value must diagnose the unreachable endpoint instead of dying in uv_close (#13719).'
+ }
+]
+
+try {
+ if (!appImage) {
+ fail(
+ 'Usage: run-linux-cli-launch-contract-docker.mjs --appimage /path/to/orca-linux.AppImage [--platform linux/amd64|linux/arm64]'
+ )
+ }
+ if (commandArgs.includes('--platform') && !platform) {
+ fail('Missing value for --platform')
+ }
+ if (platform !== null && platform !== 'linux/amd64' && platform !== 'linux/arm64') {
+ fail(`Unsupported --platform: ${platform}`)
+ }
+ if (!existsSync(appImage)) {
+ fail(`AppImage not found: ${appImage}`)
+ }
+ docker(['volume', 'create', artifactVolume], { timeoutMs: DOCKER_TIMEOUT_MS })
+ artifactVolumeCreated = true
+ buildImage()
+ stageArtifacts()
+ runContract()
+ console.log('\nLinux CLI launch contract passed.')
+} catch (error) {
+ console.error(error instanceof Error ? error.message : String(error))
+ process.exitCode = 1
+} finally {
+ for (const container of containers) {
+ docker(['rm', '-f', container], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS })
+ }
+ if (artifactVolumeCreated) {
+ docker(['volume', 'rm', artifactVolume], {
+ allowFailure: true,
+ timeoutMs: CLEANUP_TIMEOUT_MS
+ })
+ }
+ docker(['image', 'rm', tag], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS })
+}
+
+function runContract() {
+ const failures = []
+ for (const testCase of CASES) {
+ const output = runCase(testCase.name)
+ const statusMatch = /^RESULT status=(\d+)/m.exec(output)
+ if (!statusMatch) {
+ failures.push(`${testCase.name}: ${firstLine(output)}\n ${testCase.why}`)
+ console.log(` FAIL ${testCase.name} — ${firstLine(output)}`)
+ continue
+ }
+ const status = Number(statusMatch[1])
+ // Why: the harness echoes `RESULT status=N case=`, so a case whose name contains the
+ // expected substring would assert against the harness's own line instead of the CLI's output.
+ const commandOutput = output
+ .split('\n')
+ .filter((line) => !/^(?:RESULT|CRASHED|PRECONDITION_FAILED) /.test(line))
+ .join('\n')
+ const matchesOutput =
+ typeof testCase.expectOutput === 'string'
+ ? commandOutput.includes(testCase.expectOutput)
+ : testCase.expectOutput.test(commandOutput)
+ if (status !== testCase.expectStatus || !matchesOutput) {
+ failures.push(
+ `${testCase.name}: expected status ${testCase.expectStatus} and ${testCase.expectOutput}, ` +
+ `got status ${status}\n ${testCase.why}`
+ )
+ console.log(` FAIL ${testCase.name} — status ${status}`)
+ continue
+ }
+ console.log(` ok ${testCase.name} (status ${status})`)
+ }
+ if (failures.length > 0) {
+ fail(`Linux CLI launch contract failed:\n - ${failures.join('\n - ')}`)
+ }
+}
+
+function runCase(caseName) {
+ const container = `orca-cli-contract-${caseName}-${suffix}`
+ containers.add(container)
+ // FUSE and extra capabilities would invalidate the test conditions.
+ return docker(
+ [
+ 'run',
+ ...dockerPlatformArgs,
+ '--name',
+ container,
+ '--rm',
+ '-v',
+ `${artifactVolume}:/artifacts`,
+ tag,
+ caseName
+ ],
+ { allowFailure: true, capture: true, timeoutMs: CASE_TIMEOUT_MS }
+ )
+}
+
+function buildImage() {
+ console.log(`Building ${tag}…`)
+ docker(
+ [
+ 'build',
+ ...dockerPlatformArgs,
+ '--build-arg',
+ `BASE_IMAGE=${base}`,
+ '-f',
+ 'config/docker/cli-launch-contract/Dockerfile',
+ '-t',
+ tag,
+ 'config/docker/cli-launch-contract'
+ ],
+ { timeoutMs: BUILD_TIMEOUT_MS }
+ )
+}
+
+// Extract unprivileged so chrome-sandbox is not root-owned setuid.
+function stageArtifacts() {
+ console.log('Staging the AppImage payload…')
+ const container = `orca-cli-contract-stage-${suffix}`
+ containers.add(container)
+ docker(
+ [
+ 'run',
+ ...dockerPlatformArgs,
+ '--name',
+ container,
+ '--rm',
+ '-v',
+ `${artifactVolume}:/artifacts`,
+ '-v',
+ `${appImage}:/input/orca-linux.AppImage:ro`,
+ '--entrypoint',
+ 'bash',
+ tag,
+ '-lc',
+ [
+ 'set -euo pipefail',
+ 'cp /input/orca-linux.AppImage /artifacts/orca-linux.AppImage',
+ 'chmod +x /artifacts/orca-linux.AppImage',
+ 'chown -R orca:orca /artifacts',
+ // Use the AppImage runtime's no-FUSE extraction path.
+ 'cd /artifacts && runuser --user orca -- ./orca-linux.AppImage --appimage-extract >/dev/null',
+ 'test -x /artifacts/squashfs-root/resources/bin/orca-ide'
+ ].join(' && ')
+ ],
+ { timeoutMs: STAGING_TIMEOUT_MS }
+ )
+}
+
+function docker(args, options = {}) {
+ try {
+ const output = execFileSync('docker', args, {
+ encoding: 'utf8',
+ stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
+ timeout: options.timeoutMs ?? DOCKER_TIMEOUT_MS,
+ killSignal: 'SIGTERM'
+ })
+ return output ?? ''
+ } catch (error) {
+ const timedOut = error instanceof Error && 'code' in error && error.code === 'ETIMEDOUT'
+ if (timedOut) {
+ const message = `docker ${args.join(' ')} timed out after ${options.timeoutMs ?? DOCKER_TIMEOUT_MS}ms`
+ if (!options.allowFailure) {
+ fail(message)
+ }
+ return message
+ }
+ if (!options.allowFailure) {
+ fail(
+ `docker ${args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}`
+ )
+ }
+ return `${error?.stdout ?? ''}${error?.stderr ?? ''}`
+ }
+}
+
+function firstLine(value) {
+ return (value ?? '').trim().split('\n')[0] || '(no output)'
+}
+
+function valueAfter(flag) {
+ const index = commandArgs.indexOf(flag)
+ return index === -1 ? null : (commandArgs[index + 1] ?? null)
+}
+
+function fail(message) {
+ throw new Error(message)
+}
diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs
index a723a9a6ad0..dddfa3e0148 100644
--- a/config/scripts/run-ssh-docker-e2e.mjs
+++ b/config/scripts/run-ssh-docker-e2e.mjs
@@ -71,7 +71,9 @@ const result = spawnSync(
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
+ 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
diff --git a/config/scripts/shebang-script-line-ending-pin.test.mjs b/config/scripts/shebang-script-line-ending-pin.test.mjs
new file mode 100644
index 00000000000..5537d258096
--- /dev/null
+++ b/config/scripts/shebang-script-line-ending-pin.test.mjs
@@ -0,0 +1,70 @@
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Guard the `.gitattributes` pin that keeps `config/scripts` scripts on LF.
+ *
+ * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a
+ * pin a Windows checkout gets CRLF. Vite's SSR transform locates the shebang
+ * with `/^#!.*\n/` — `\r` is a JS regex line terminator, so `.` never matches it
+ * and the pattern misses on CRLF. The hoisted import/export preamble then lands
+ * at offset 0, ahead of the shebang, which in turn defeats the `code[0] === '#'`
+ * guard that blanks it. A literal `#!` survives into the middle of the module and
+ * every suite importing the script dies at load with a SyntaxError.
+ *
+ * Scoped to `config/scripts` because that is where tests import scripts. Other
+ * shebanged `.mjs` in the tree are spawned, not imported, so they cannot hit this.
+ */
+const projectDir = resolve(import.meta.dirname, '../..')
+const SCRIPT_DIRECTORY = 'config/scripts'
+
+function git(args) {
+ return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' })
+}
+
+/** `git check-attr -z` emits NUL-separated path/attr/value triples. */
+function eolAttributes(paths) {
+ const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0')
+ const found = new Map()
+ for (let index = 0; index + 2 < fields.length; index += 3) {
+ found.set(fields[index], fields[index + 2])
+ }
+ return found
+}
+
+function shebangScripts() {
+ return git(['ls-files', '-z', '--', `${SCRIPT_DIRECTORY}/*.mjs`])
+ .split('\0')
+ .filter(Boolean)
+ .filter((path) => readFileSync(join(projectDir, path), 'utf8').startsWith('#!'))
+}
+
+describe('config/scripts line-ending pin', () => {
+ it('pins every shebanged script to LF', () => {
+ const scripts = shebangScripts()
+ expect(scripts.length).toBeGreaterThan(0)
+
+ const attributes = eolAttributes(scripts)
+ const unpinned = scripts.filter((path) => attributes.get(path) !== 'lf')
+
+ expect(
+ unpinned,
+ 'A shebanged script left on the platform default gets CRLF on Windows, ' +
+ 'which makes every suite importing it fail to load. Pin it in .gitattributes.'
+ ).toEqual([])
+ })
+
+ // Why: without these the assertion above still passes against a pattern so broad
+ // it says nothing, or so narrow it only covers the files that exist today.
+ it.each([
+ ['config/scripts/example.mjs', 'lf'],
+ ['config/scripts/nested/deeper/example.mjs', 'lf'],
+ ['config/scripts-extra/example.mjs', 'unspecified'],
+ ['vendor/config/scripts/example.mjs', 'unspecified'],
+ ['config/scripts/example.mjsx', 'unspecified']
+ ])('resolves %s to eol=%s', (path, expected) => {
+ expect(eolAttributes([path]).get(path)).toBe(expected)
+ })
+})
diff --git a/config/scripts/skill-sharing-release-workflow.test.mjs b/config/scripts/skill-sharing-release-workflow.test.mjs
index 2978b058305..8b72880e3bb 100644
--- a/config/scripts/skill-sharing-release-workflow.test.mjs
+++ b/config/scripts/skill-sharing-release-workflow.test.mjs
@@ -31,7 +31,7 @@ describe('skill-sharing release workflow', () => {
expect(macBuild.needs).toContain('release-preflight')
})
- it('blocks publication on native Windows, macOS, and the Linux floor', () => {
+ it('blocks on macOS and the Linux floor while keeping Windows diagnostic', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const publishNeeds = workflow.jobs['publish-release'].needs
@@ -40,6 +40,7 @@ describe('skill-sharing release workflow', () => {
{ os: 'macos-15', platform: 'mac' },
{ os: 'windows-2022', platform: 'windows' }
])
+ expect(platform['continue-on-error']).toBe("${{ matrix.platform == 'windows' }}")
expect(linux.container).toBe('ubuntu:20.04')
expect(publishNeeds).toContain('skill-sharing-release-gate')
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
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/config/scripts/verify-cli-bin.mjs b/config/scripts/verify-cli-bin.mjs
index a9fa71ce2e7..cdc56401262 100755
--- a/config/scripts/verify-cli-bin.mjs
+++ b/config/scripts/verify-cli-bin.mjs
@@ -5,15 +5,14 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'nod
import path from 'node:path'
import { pathToFileURL } from 'node:url'
-const OUT_COMMONJS_PACKAGE_JSON = `${JSON.stringify(
- {
- name: 'orca-compiled-output',
- type: 'commonjs',
- private: true
- },
- null,
- 2
-)}\n`
+// Electron packaging restamps the channel-specific version after compilation.
+function buildOutPackageJson(version) {
+ return `${JSON.stringify(
+ { name: 'orca-compiled-output', type: 'commonjs', private: true, version },
+ null,
+ 2
+ )}\n`
+}
/**
* Verifies the published CLI entrypoint and the module-type boundary for the
@@ -49,7 +48,7 @@ export function verifyPackageCliBin({
const outPackageJsonPath = path.join(projectDir, 'out', 'package.json')
if (fixPackageJson) {
mkdirSync(path.dirname(outPackageJsonPath), { recursive: true })
- writeFileSync(outPackageJsonPath, OUT_COMMONJS_PACKAGE_JSON, 'utf8')
+ writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8')
}
let outPackageJson
try {
diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs
index 55ec8ca7724..3a138ed894b 100644
--- a/config/scripts/verify-linux-glibc-floor.cjs
+++ b/config/scripts/verify-linux-glibc-floor.cjs
@@ -164,6 +164,78 @@ function findMissingProviderDeps(importedSymbols, neededLibraries) {
return missing
}
+// ELF e_machine values for the Linux slices we package. Names match electron-builder's Arch enum.
+const ELF_MACHINE_BY_ARCH = Object.freeze({ x64: 0x3e, arm64: 0xb7 })
+const ARCH_BY_ELF_MACHINE = Object.freeze({ 0x3e: 'x64', 0xb7: 'arm64' })
+
+/**
+ * ELF `e_machine`, or null when the file is not a readable little-endian ELF.
+ *
+ * Why this is checked at all: cross-building an arm64 package on an x64 host can silently pack an
+ * x86-64 `pty.node` into the arm64 slice — the rebuild logs a forced arm64 rebuild and still ships
+ * the host's binary. Every other gate here inspects symbol versions, which are perfectly valid on
+ * the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the app loaded, then
+ * failed with "Failed to load native module: pty.node".
+ */
+function readElfMachine(filePath) {
+ let fd
+ try {
+ fd = openSync(filePath, 'r')
+ const header = Buffer.alloc(20)
+ if (readSync(fd, header, 0, 20, 0) !== 20) {
+ return null
+ }
+ // EI_DATA (offset 5) must be ELFDATA2LSB for a little-endian e_machine read.
+ if (header[5] !== 1) {
+ return null
+ }
+ return header.readUInt16LE(18)
+ } catch {
+ return null
+ } finally {
+ if (fd !== undefined) {
+ closeSync(fd)
+ }
+ }
+}
+
+// Arch tokens that appear in vendored per-architecture package/directory names.
+const ARCH_TOKEN_PATTERN = /(?:^|[^a-z0-9])(arm64|aarch64|x64|x86_64)(?:[^a-z0-9]|$)/i
+const ARCH_BY_TOKEN = Object.freeze({ arm64: 'arm64', aarch64: 'arm64', x64: 'x64', x86_64: 'x64' })
+
+/**
+ * The architecture a path advertises, or null when it advertises none.
+ *
+ * Why this matters: some dependencies ship every architecture and let their loader pick
+ * (`@parcel/watcher-linux-arm64-glibc/watcher.node` is arm64 on purpose inside an x64 build). Those
+ * must be judged against the arch their own path declares, not against the slice.
+ */
+function declaredArchFromPath(filePath) {
+ const match = ARCH_TOKEN_PATTERN.exec(filePath)
+ return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null
+}
+
+function findArchViolation(filePath, targetArch) {
+ // A path that names an architecture is judged against that name, so a per-arch vendored package
+ // is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught.
+ const declared = declaredArchFromPath(filePath)
+ const expectedArch = declared ?? targetArch
+ const expected = ELF_MACHINE_BY_ARCH[expectedArch]
+ if (expected === undefined) {
+ return null
+ }
+ const machine = readElfMachine(filePath)
+ if (machine === null || machine === expected) {
+ return null
+ }
+ return {
+ machine,
+ actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}`,
+ expectedArch,
+ declared: declared !== null
+ }
+}
+
function isElfFile(filePath) {
let fd
try {
@@ -312,6 +384,7 @@ function readImportedSymbols(filePath, objdumpPath) {
*/
function verifyLinuxGlibcFloor(rootDir, options = {}) {
const binaries = collectNativeBinaries(rootDir)
+ const targetArch = options.targetArch
if (binaries.length === 0) {
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
return
@@ -327,6 +400,28 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
)
}
+ // Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but
+ // meaningless, so reporting a floor violation for it would send the reader down the wrong path.
+ const archOffenders = binaries
+ .map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) }))
+ .filter(({ violation }) => violation !== null)
+ if (archOffenders.length > 0) {
+ const detail = archOffenders
+ .map(
+ ({ filePath, violation }) =>
+ ` ${relative(rootDir, filePath) || filePath} is ${violation.actual}, expected ` +
+ `${violation.expectedArch}${violation.declared ? ' (from its own path)' : ''}`
+ )
+ .join('\n')
+ throw new Error(
+ `[verify-linux-glibc-floor] ${archOffenders.length} bundled native binar` +
+ `${archOffenders.length === 1 ? 'y is' : 'ies are'} built for the wrong architecture ` +
+ `(target ${targetArch}), so the app will fail to load them at runtime:\n${detail}\n` +
+ 'Cross-building a Linux slice can pack the host architecture despite a forced rebuild; ' +
+ 'build this slice on a native runner.'
+ )
+ }
+
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
@@ -375,6 +470,10 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
module.exports = {
MIN_GLIBC,
+ ELF_MACHINE_BY_ARCH,
+ readElfMachine,
+ declaredArchFromPath,
+ findArchViolation,
VERSION_FLOORS,
FLOOR_LABEL,
RELOCATED_SYMBOL_PROVIDERS,
diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs
index 603e4e85c00..d8d82165053 100644
--- a/config/scripts/verify-linux-glibc-floor.test.mjs
+++ b/config/scripts/verify-linux-glibc-floor.test.mjs
@@ -6,6 +6,10 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
+ readElfMachine,
+ declaredArchFromPath,
+ findArchViolation,
+ ELF_MACHINE_BY_ARCH,
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
@@ -321,3 +325,86 @@ describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
}
})
})
+
+/** Minimal little-endian 64-bit ELF header with the given e_machine. */
+function elfHeader(machine) {
+ const header = Buffer.alloc(64)
+ header.write('\x7fELF', 0, 'latin1')
+ header[4] = 2 // ELFCLASS64
+ header[5] = 1 // ELFDATA2LSB
+ header[6] = 1 // EV_CURRENT
+ header.writeUInt16LE(3, 16) // ET_DYN
+ header.writeUInt16LE(machine, 18)
+ return header
+}
+
+describe('bundled native binary architecture', () => {
+ it('reads e_machine from a little-endian ELF', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const file = join(dir, 'pty.node')
+ await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64))
+ expect(readElfMachine(file)).toBe(ELF_MACHINE_BY_ARCH.arm64)
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ // The observed failure: cross-building arm64 on an x64 host packed an x86-64 pty.node, whose
+ // symbol versions are valid, so every other gate here passed it.
+ // Real CI hit: @parcel/watcher ships every architecture and its loader picks the match, so the
+ // arm64 copy is present in an x64 build on purpose.
+ it('accepts a per-arch vendored package that matches its own path', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const pkg = join(dir, '@parcel', 'watcher-linux-arm64-glibc')
+ await mkdir(pkg, { recursive: true })
+ const file = join(pkg, 'watcher.node')
+ await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64))
+ expect(declaredArchFromPath(file)).toBe('arm64')
+ expect(findArchViolation(file, 'x64')).toBeNull()
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ // But a path that names an arch must actually hold it — this is the Pi 5 failure.
+ it('flags a binary that contradicts the architecture its own path names', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const nested = join(dir, 'bin', 'linux-arm64-148')
+ await mkdir(nested, { recursive: true })
+ const file = join(nested, 'node-pty.node')
+ await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
+ expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' })
+ // Still caught even when the slice being built is x64.
+ expect(findArchViolation(file, 'x64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' })
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('flags an x86-64 binary in an arm64 slice', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const file = join(dir, 'pty.node')
+ await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
+ expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64' })
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('accepts a matching architecture', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const file = join(dir, 'pty.node')
+ await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
+ expect(findArchViolation(file, 'x64')).toBeNull()
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('stays silent when no target architecture is supplied', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const file = join(dir, 'pty.node')
+ await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
+ expect(findArchViolation(file, undefined)).toBeNull()
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('ignores a file that is not a readable little-endian ELF', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
+ const file = join(dir, 'not-elf.node')
+ await writeFile(file, Buffer.from('not an elf at all'))
+ expect(readElfMachine(file)).toBeNull()
+ expect(findArchViolation(file, 'arm64')).toBeNull()
+ await rm(dir, { recursive: true, force: true })
+ })
+})
diff --git a/config/scripts/win32-test-lane-registration.test.mjs b/config/scripts/win32-test-lane-registration.test.mjs
new file mode 100644
index 00000000000..a1566c55c31
--- /dev/null
+++ b/config/scripts/win32-test-lane-registration.test.mjs
@@ -0,0 +1,673 @@
+import { readFileSync, statSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { parse } from 'yaml'
+import { scanSourceTree, stripComments } from '../../src/shared/source-scan/source-tree-scan'
+import { classifyPrJobs } from './pr-code-change-scope.mjs'
+
+/**
+ * Every Windows-gated test file must be registered in BOTH Windows-lane lists.
+ *
+ * PR CI has exactly one job on a Windows runner -- asserted below on any
+ * `runs-on` spelling that could land there, because that premise is what makes
+ * this guard meaningful -- and it runs a curated explicit file list. Everything else runs on `ubuntu-latest`, where a Windows-gated
+ * suite self-skips and reports success. So a new Windows-gated file that nobody
+ * registers executes on no machine and passes green, silently. A recent
+ * security effort added six such files; five ran nowhere, including one whose
+ * whole point was asserting a native addon's bytes no longer contain a flagged
+ * primitive. Registering the instances did not hold -- a sixth arrived from
+ * unrelated work while the first five were being fixed -- so the class needs a
+ * guard.
+ *
+ * Both lists matter and being in one is not enough: `WINDOWS_PACKAGE_TESTS` in
+ * pr-code-change-scope.mjs decides whether the `package_windows` job RUNS at
+ * all for a diff, and the workflow step's vitest argv decides whether the FILE
+ * runs once the job started.
+ *
+ * WHAT THIS DETECTS -- a file is Windows-gated when its name is `*.win32.test.*`
+ * / `*.win32.spec.*`, or when it contains ANY suite-level gate, nested ones
+ * included, spelled:
+ * - `describe.runIf()`, `describe.skipIf()`
+ * - `const d = ? describe : describe.skip`, and the
+ * `? describe.skip : describe` inversion
+ * where the condition is `process.platform === 'win32'` / `!== 'win32'`, a
+ * compound ` && `, or a `const`/`let` in the same file
+ * assigned from either -- so `const RUN_REAL = platform === 'win32' && env…`
+ * used as `describe.runIf(RUN_REAL)` is detected, whatever the flag is named
+ * and whichever polarity it was written in. Quote style, spacing and the
+ * `describe`/`suite` spelling are tolerated. Nested gates count because the
+ * Windows lane runs whole files: a win32-only block buried three levels down
+ * still runs on no machine unless the file is registered.
+ *
+ * WHAT THIS CANNOT DETECT -- known blind spots, each deliberate:
+ * - `it`/`test`-level gates. A single win32-only case inside a cross-platform
+ * suite still leaves the file running its other cases on ubuntu, and
+ * pulling all such files -- about thirty, though the figure moves with
+ * which gate spellings you count, so do not lean on it -- into the serial
+ * Windows job is not the trade CI wants. This is the largest limit, and it
+ * is a policy choice, not an oversight: a suite-level gate means a whole
+ * block exists only for Windows, which is the shape worth a lane entry.
+ * - a gate whose condition crosses a module boundary or a function call --
+ * an imported flag, an imported `describeOnWindows`, `isWindows()`.
+ * `legacy-wsl-runtime-auth-drain-apply-script.test.ts` imports its
+ * `isWindows`; it happens to be a POSIX-only gate, so nothing is missed
+ * today, but a win32-only one written that way would be.
+ * - `runIf( || )` and `skipIf( && )` are rejected on
+ * purpose: both can run off Windows, so neither is a win32-only gate. That
+ * holds whether the condition is written at the gate or routed through a
+ * named flag -- the two spellings used to disagree.
+ * - whether a registered suite EXECUTES. Registration is what is asserted. A
+ * suite gated on win32 plus an env var stays skipped on the CI runner even
+ * when registered -- see MANUAL_OPT_IN -- and a path registered but gated
+ * for another platform is not caught either.
+ * - whether the `package_windows` job is triggered for a given diff, or
+ * whether the registered test asserts anything worth running.
+ *
+ * Growth of the two grandfathered lists is capped by literals, but only review
+ * stops someone raising a cap. The caps make that an explicit, visible edit.
+ */
+
+const projectDir = resolve(import.meta.dirname, '../..')
+const WINDOWS_LANE_JOB = 'package_windows'
+const WINDOWS_LANE_STEP = 'Test Windows-specific boundaries'
+const WINDOWS_LANE_RUNNER = 'windows-2022'
+
+/**
+ * Windows-gated files that predate this guard and are registered in neither
+ * list. Shrink-only: registering one means deleting its line here. Never add.
+ */
+const UNREGISTERED_ON_MAIN = [
+ // Suite gated with `describe.skipIf(platform !== 'win32')`; the cross-platform
+ // half of the file still runs on ubuntu, the Windows half runs nowhere.
+ 'src/main/antigravity/windows-hook-payload-delivery.test.ts',
+ // `.win32.test.ts` by name yet in neither list -- the plainest instance of the class.
+ 'src/main/daemon/node-pty-windows-input-error.win32.test.ts',
+ // Same shape as the antigravity file: a win32-only sibling suite that never runs.
+ 'src/main/grok/windows-grok-hook-script.test.ts',
+ // Whole file is `describe.runIf(platform === 'win32')`; runs on no machine.
+ 'src/main/ipc/preflight-windows-path-refresh.repro.test.ts',
+ // Nested `describe.skipIf(!isWindows)` real-shell block; never exercised in CI.
+ 'src/main/ipc/pty-encoding.test.ts',
+ // `describeWindows` ternary over the whole file; runs on no machine.
+ 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts',
+ // Whole file is `describe.runIf(platform === 'win32')`; runs on no machine.
+ 'src/main/startup/windows-shell-path-restoration.windows.test.ts',
+ // Whole file is `describe.skipIf(platform !== 'win32')`; runs on no machine.
+ 'src/shared/setup-agent-sequencing.windows.test.ts'
+]
+
+/**
+ * Windows-gated suites that ALSO require an opt-in env var, so registering them
+ * would not make them execute -- they are run by hand against a real distro or
+ * a real filesystem. Excluded deliberately and visibly rather than by accident
+ * of a regex; each entry is asserted below to be genuinely env-gated, so this
+ * list cannot become a place to park a file someone did not want to register.
+ */
+const MANUAL_OPT_IN = [
+ // `runIf(platform === 'win32' && Boolean(distro))`, distro from ORCA_TEST_WSL_DISTRO.
+ 'src/main/git/runner-wsl-linked-gitdir-windows.test.ts',
+ // `runRealWsl = … && ORCA_REAL_WSL_BANNER_TEST === '1'`; needs a real distro.
+ 'src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts',
+ // `RUN_REAL_WINDOWS = platform === 'win32' && ORCA_REAL_WINDOWS_SKILL_TEST === '1'`.
+ 'src/main/skills/skill-windows-rename-contention.integration.test.ts',
+ // Same flag; installs into a real Windows workspace.
+ 'src/main/skills/skill-windows-workspace.integration.test.ts',
+ // `RUN_REAL_WSL = … && ORCA_REAL_WSL_SKILL_TEST === '1'`; real distro filesystem.
+ 'src/main/skills/skill-wsl-delete.integration.test.ts',
+ // Same flag; real WSL install transactions.
+ 'src/main/skills/skill-wsl-install-transaction.integration.test.ts',
+ // Same flag; real WSL POSIX semantics.
+ 'src/main/skills/skill-wsl-posix-semantics.integration.test.ts',
+ // `runRealWsl = … && ORCA_REAL_WSL_DELETE_TEST === '1'`; real distro traversal race.
+ 'src/main/wsl-approved-root-race.wsl.test.ts',
+ // Same flag; real UNC delete against a distro.
+ 'src/main/wsl-unc-delete.wsl.test.ts',
+ // `enabled = platform === 'win32' && ORCA_REAL_WSL_RUNNER_TEST === '1'`; mutates a real distro's ~/.profile.
+ 'src/main/wsl/wsl-runner.wsl.test.ts'
+]
+
+/** Caps so growing either list is two deliberate edits, not one. */
+const UNREGISTERED_MAX = 8
+const MANUAL_OPT_IN_MAX = 10
+
+/**
+ * Floor for the Windows-gated population, so a broken walk or a regex that
+ * stops matching cannot make the guard pass by finding nothing. Only ever
+ * lowered, and only when a gated file is genuinely deleted.
+ */
+const GATED_FILE_FLOOR = 23
+
+const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:ts|tsx|mjs|cjs|js)$/
+
+/**
+ * Mobile has its own vitest run and never touches the desktop Windows job:
+ * `classifyPrJobs` reports `package_windows: false` for every `mobile/` path,
+ * so a gated file there could not satisfy this guard even in principle.
+ */
+const UNREACHABLE_BY_THE_WINDOWS_LANE = 'mobile/'
+
+/**
+ * This file quotes every gate spelling as a fixture, so it matches its own
+ * matcher. It is not gated -- it must run on ubuntu, since a guard about
+ * Windows CI that only ran on Windows would be self-defeating. Exempt by exact
+ * path, never by directory, so a real gated file in config/scripts is caught.
+ */
+const SCANNER_SELF_PATH = 'config/scripts/win32-test-lane-registration.test.mjs'
+
+export function isScannerSelfPath(path) {
+ return path === SCANNER_SELF_PATH
+}
+
+const WIN32_TRUE_EXPRESSION = String.raw`process\.platform\s*===\s*['"]win32['"]`
+const WIN32_FALSE_EXPRESSION = String.raw`process\.platform\s*!==\s*['"]win32['"]`
+const SUITE = String.raw`(?:describe|suite)`
+
+/**
+ * Named flags resolved from their assignment in the same file, so polarity is
+ * read rather than guessed from the name.
+ *
+ * Why the trailing lookahead: `const d = platform === 'win32' ? describe : …`
+ * is a suite alias, not a boolean, and must not be collected as one.
+ *
+ * Why the two patterns differ on `&&`: a second conjunct NARROWS a
+ * truthy-on-Windows flag, which stays Windows-only, but WIDENS a
+ * falsy-on-Windows one -- `p = platform !== 'win32' && x` used as `skipIf(p)`
+ * runs on Windows AND on POSIX whenever `x` is false, so it is not a
+ * Windows-only gate. One lookahead shared across both polarities had that
+ * backwards, and routing the condition through a named flag flipped the answer
+ * the literal form got right. `||` is excluded from both.
+ */
+const FLAG_TRUE_ASSIGNMENT = new RegExp(
+ String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${WIN32_TRUE_EXPRESSION}(?=\s*(?:&&|;|\r?\n|$))`,
+ 'g'
+)
+const FLAG_FALSE_ASSIGNMENT = new RegExp(
+ String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${WIN32_FALSE_EXPRESSION}(?=\s*(?:;|\r?\n|$))`,
+ 'g'
+)
+
+function escapeForAlternation(name) {
+ return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/** Never-matching branch, so an empty flag set cannot widen a pattern. */
+const MATCHES_NOTHING = String.raw`(?!)`
+
+function alternation(names) {
+ return names.length === 0 ? MATCHES_NOTHING : names.map(escapeForAlternation).join('|')
+}
+
+function buildGates(source) {
+ const trueOnWindows = [...source.matchAll(FLAG_TRUE_ASSIGNMENT)].map(([, name]) => name)
+ const falseOnWindows = [...source.matchAll(FLAG_FALSE_ASSIGNMENT)].map(([, name]) => name)
+ const isTrue = `(?:${WIN32_TRUE_EXPRESSION}|\\b(?:${alternation(trueOnWindows)})\\b)`
+ const isFalse = `(?:${WIN32_FALSE_EXPRESSION}|!\\s*(?:${alternation(trueOnWindows)})\\b|\\b(?:${alternation(falseOnWindows)})\\b)`
+ return [
+ // `\)` or `&&` after the condition: a bare gate, or a compound one whose
+ // remaining conjuncts only narrow it further. Anchoring on `\)` alone was
+ // this guard's own bug -- `runIf(win32 && hasAddon)` went undetected.
+ new RegExp(String.raw`\b${SUITE}\s*\.\s*runIf\s*\(\s*${isTrue}\s*(?:\)|&&)`),
+ new RegExp(String.raw`\b${SUITE}\s*\.\s*skipIf\s*\(\s*${isFalse}\s*(?:\)|\|\|)`),
+ // `(?!\s*\.\s*skip)`: `platform === 'win32' ? describe.skip : describe` is
+ // the POSIX-only gate, the exact opposite of the class, and seven files
+ // use it.
+ new RegExp(String.raw`=\s*${isTrue}\s*\?\s*${SUITE}\s*(?!\s*\.\s*skip)`),
+ new RegExp(String.raw`=\s*${isFalse}\s*\?\s*${SUITE}\s*\.\s*skip`)
+ ]
+}
+
+/** Exported shape of the rule, so the fixtures below exercise the real matcher. */
+export function isWindows32GatedTestFile(path, source) {
+ if (/\.win32\.(?:test|spec)\./.test(path)) {
+ return true
+ }
+ // Prose about a gate is not a gate; the shared stripper tracks quote state so
+ // a slash-star inside a string cannot blank live code.
+ const code = stripComments(source)
+ return buildGates(code).some((gate) => gate.test(code))
+}
+
+/**
+ * True when the env read REACHES the gate: the win32 check is compound, and one
+ * of its other conjuncts either reads `process.env` itself or names a const
+ * that does.
+ *
+ * "Mentions an env var anywhere in the file" is not enough and was the earlier
+ * bug here. `runIf(platform === 'win32' && hasAddon)` in a file that happens to
+ * read `process.env.RUNNER_TEMP` for a temp dir is a test CI COULD run -- the
+ * native-addon-bytes shape, exactly what this effort exists to keep in CI --
+ * and it would have parked in MANUAL_OPT_IN unnoticed. Only the cap number
+ * stood in the way, and a number is not an argument.
+ *
+ * One hop is enough for every real case: `distro = process.env.ORCA_TEST_WSL_DISTRO`
+ * then `runIf(platform === 'win32' && Boolean(distro))`. Deeper chains fail
+ * closed -- the file reads as registrable, which is the safe direction.
+ */
+const WIN32_CONJUNCT = new RegExp(String.raw`${WIN32_TRUE_EXPRESSION}\s*&&([^\n]*)`, 'g')
+const ENV_READ = /process\.env\.[A-Za-z0-9_]+/
+const IDENTIFIER = /[A-Za-z_$][\w$]*/g
+
+function isAssignedFromEnv(name, code) {
+ return new RegExp(
+ String.raw`(?:const|let|var)\s+${escapeForAlternation(name)}\s*=[^\n]*process\.env\.`
+ ).test(code)
+}
+
+export function requiresEnvOptIn(source) {
+ const code = stripComments(source)
+ return [...code.matchAll(WIN32_CONJUNCT)].some(([, conjunct]) => {
+ if (ENV_READ.test(conjunct)) {
+ return true
+ }
+ return [...conjunct.matchAll(IDENTIFIER)].some(([name]) => isAssignedFromEnv(name, code))
+ })
+}
+
+/**
+ * Any `runs-on` that could put a job on Windows.
+ *
+ * Not an equality test against `windows-2022`: `windows-latest` resolves to the
+ * same image today, a label array or `{ group, labels }` object is valid YAML
+ * here, and a `${{ matrix.os }}` expression cannot be resolved from the file at
+ * all. An unresolvable expression counts as "could be Windows" so it fails
+ * closed -- someone has to look rather than have a second lane appear silently.
+ */
+export function couldRunOnWindows(runsOn) {
+ const labels =
+ typeof runsOn === 'string'
+ ? [runsOn]
+ : Array.isArray(runsOn)
+ ? runsOn
+ : [...(runsOn?.labels ?? []), runsOn?.group ?? ''].flat()
+ return labels.some((label) => /windows/i.test(String(label)) || String(label).includes('${{'))
+}
+
+/** The vitest argv of the one Windows job's one curated-file step. */
+function readWindowsWorkflow() {
+ const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
+ const jobs = Object.entries(workflow.jobs ?? {})
+ const windowsJobs = jobs.filter(([, job]) => couldRunOnWindows(job?.['runs-on']))
+ const steps = workflow.jobs?.[WINDOWS_LANE_JOB]?.steps ?? []
+ const step = steps.find((candidate) => candidate?.name === WINDOWS_LANE_STEP)
+ if (!step) {
+ throw new Error(
+ `No "${WINDOWS_LANE_STEP}" step in the ${WINDOWS_LANE_JOB} job of .github/workflows/pr.yml. ` +
+ 'If it was renamed, update WINDOWS_LANE_STEP here -- do not delete this guard.'
+ )
+ }
+ const run = String(step.run ?? '')
+ if (!run.includes('vitest run')) {
+ throw new Error(
+ `The "${WINDOWS_LANE_STEP}" step no longer invokes vitest; this guard is stale.`
+ )
+ }
+ return {
+ windowsJobNames: windowsJobs.map(([name]) => name),
+ laneFiles: run.split(/\s+/).filter((token) => TEST_FILE_PATTERN.test(token))
+ }
+}
+
+const { windowsJobNames, laneFiles } = readWindowsWorkflow()
+const scannedTestFiles = scanSourceTree(projectDir, {
+ includeTests: true,
+ extensions: TEST_FILE_PATTERN
+}).filter(({ relativePath }) => !relativePath.startsWith(UNREACHABLE_BY_THE_WINDOWS_LANE))
+const gatedFiles = scannedTestFiles
+ .filter(({ relativePath }) => !isScannerSelfPath(relativePath))
+ .filter(({ relativePath, source }) => isWindows32GatedTestFile(relativePath, source))
+ .map(({ relativePath }) => relativePath)
+
+/**
+ * Why the classifier and not the literal list: `WINDOWS_PACKAGE_TESTS` is not
+ * exported, and the classifier is what CI actually consults. It inherits
+ * `classifyPrJobs`'s force-all, so a path under GLOBAL_FORCE_PREFIXES would
+ * read as registered without being listed -- no test file is one today.
+ */
+function isInClassifier(path) {
+ return classifyPrJobs([path])[WINDOWS_LANE_JOB] === true
+}
+
+function registrationFailure(path) {
+ const missing = []
+ if (!laneFiles.includes(path)) {
+ missing.push(
+ `add "${path}" to the "${WINDOWS_LANE_STEP}" vitest argv in .github/workflows/pr.yml ` +
+ `(job ${WINDOWS_LANE_JOB})`
+ )
+ }
+ if (!isInClassifier(path)) {
+ missing.push(
+ `add '${path}' to WINDOWS_PACKAGE_TESTS in config/scripts/pr-code-change-scope.mjs`
+ )
+ }
+ return missing.length === 0 ? null : `${path}: ${missing.join('; and ')}`
+}
+
+function sourceOf(path) {
+ return readFileSync(join(projectDir, path), 'utf8')
+}
+
+describe('Windows-gated test files are registered in the Windows CI lane', () => {
+ it('scans a plausible number of test files', () => {
+ // A broken root or extension filter would make every assertion below vacuous.
+ expect(scannedTestFiles.length).toBeGreaterThan(5000)
+ })
+
+ it('has exactly one windows-2022 job to register into', () => {
+ // The whole premise: one Windows lane, one curated list. A second lane would
+ // mean a file could be registered in the wrong one and still run nowhere.
+ expect(
+ windowsJobNames,
+ `Expected only ${WINDOWS_LANE_JOB} to run on ${WINDOWS_LANE_RUNNER}.`
+ ).toEqual([WINDOWS_LANE_JOB])
+ })
+
+ it('parses a plausible Windows lane invocation', () => {
+ expect(laneFiles.length).toBeGreaterThan(15)
+ const missingFromDisk = laneFiles.filter((path) => {
+ try {
+ return !statSync(join(projectDir, path)).isFile()
+ } catch {
+ return true
+ }
+ })
+ expect(
+ missingFromDisk,
+ 'The Windows lane invokes vitest on paths that do not exist -- vitest will run nothing for them.'
+ ).toEqual([])
+ })
+
+ it('rediscovers Windows-gated files that are already registered', () => {
+ // Both discovery paths, proven against real files rather than fixtures: one
+ // found by filename plus ternary alias, one found only by its gate
+ // expression because its name says nothing about Windows gating.
+ expect(gatedFiles).toContain('src/shared/child-process/windows-command-line.win32.test.ts')
+ expect(gatedFiles).toContain('src/main/agent-hooks/windows-hook-payload-delivery.test.ts')
+ // And a compound gate, the case this guard was blind to at first.
+ expect(gatedFiles).toContain('src/main/git/runner-wsl-linked-gitdir-windows.test.ts')
+ })
+
+ it('exempts itself, and nothing else, from the scan', () => {
+ expect(scannedTestFiles.map(({ relativePath }) => relativePath)).toContain(SCANNER_SELF_PATH)
+ // The exemption is load-bearing only while the fixtures below still match.
+ expect(isWindows32GatedTestFile(SCANNER_SELF_PATH, sourceOf(SCANNER_SELF_PATH))).toBe(true)
+ expect(gatedFiles).not.toContain(SCANNER_SELF_PATH)
+ // The other half of the claim: no sibling rides the exemption.
+ expect(isScannerSelfPath('config/scripts/pr-code-change-scope.test.mjs')).toBe(false)
+ })
+
+ it('holds the Windows-gated population at or above the floor', () => {
+ // Bounding by the grandfathered lists' lengths would be trivially true --
+ // they move together. The floor is a literal for that reason.
+ expect(
+ gatedFiles.length,
+ `Found ${gatedFiles.length} Windows-gated test files; the floor is ${GATED_FILE_FLOOR}. ` +
+ 'A drop means the scan stopped matching, not that the files went away. Lower the floor ' +
+ 'only for a genuine deletion.'
+ ).toBeGreaterThanOrEqual(GATED_FILE_FLOOR)
+ })
+
+ it('confirms the classifier distinguishes registered from unregistered paths', () => {
+ // Without this, a classifier that answered true for everything would make
+ // the registration assertion below pass for free.
+ expect(isInClassifier('src/main/windows/windows-pty-job.win32.test.ts')).toBe(true)
+ expect(isInClassifier('src/main/windows/not-a-real-file.win32.test.ts')).toBe(false)
+ })
+
+ it('has every Windows-gated test file in both registration lists', () => {
+ const grandfathered = new Set([...UNREGISTERED_ON_MAIN, ...MANUAL_OPT_IN])
+ const failures = gatedFiles
+ .filter((path) => !grandfathered.has(path))
+ .map(registrationFailure)
+ .filter((failure) => failure !== null)
+ expect(
+ failures,
+ 'A Windows-gated test file is missing from a Windows CI registration list. It self-skips on ' +
+ 'ubuntu and reports success, so it runs on no machine. Both lists are required: ' +
+ 'WINDOWS_PACKAGE_TESTS decides whether the package_windows job runs for a diff, the ' +
+ 'workflow argv decides whether the file runs once it started. Fix each line below.'
+ ).toEqual([])
+ })
+
+ it('has no stale entry in either grandfathered list', () => {
+ const stale = [...UNREGISTERED_ON_MAIN, ...MANUAL_OPT_IN].filter(
+ (path) => !gatedFiles.includes(path) || registrationFailure(path) === null
+ )
+ expect(
+ stale,
+ 'These files are no longer unregistered Windows-gated debt -- they were registered, ' +
+ 'renamed, un-gated, or deleted. Delete each line from UNREGISTERED_ON_MAIN or ' +
+ 'MANUAL_OPT_IN; the lists only ever shrink.'
+ ).toEqual([])
+ })
+
+ it('caps growth of both grandfathered lists', () => {
+ expect(
+ UNREGISTERED_ON_MAIN.length,
+ 'Never raise UNREGISTERED_MAX. Register the file instead.'
+ ).toBeLessThanOrEqual(UNREGISTERED_MAX)
+ expect(
+ MANUAL_OPT_IN.length,
+ 'Never raise MANUAL_OPT_IN_MAX to avoid registering a file that CI could actually run.'
+ ).toBeLessThanOrEqual(MANUAL_OPT_IN_MAX)
+ })
+
+ it('keeps MANUAL_OPT_IN to suites CI genuinely cannot run', () => {
+ // Otherwise this list is just a quieter way to skip registration.
+ const notActuallyOptIn = MANUAL_OPT_IN.filter((path) => !requiresEnvOptIn(sourceOf(path)))
+ expect(
+ notActuallyOptIn,
+ 'A MANUAL_OPT_IN entry has no env-var opt-in, so registering it WOULD make it run. ' +
+ 'Register it in both lists and delete the line.'
+ ).toEqual([])
+ })
+
+ it('keeps every UNREGISTERED_ON_MAIN file ineligible for MANUAL_OPT_IN', () => {
+ // The two lists must not be interchangeable: debt that CI could run must
+ // not be re-labelled as manual to make the debt cap look better.
+ const movable = UNREGISTERED_ON_MAIN.filter((path) => requiresEnvOptIn(sourceOf(path)))
+ expect(
+ movable,
+ 'This file is registrable; it cannot be reclassified as MANUAL_OPT_IN.'
+ ).toEqual([])
+ })
+})
+
+describe('manual opt-in classification', () => {
+ it('requires the env read to reach the gate', () => {
+ // The parking attack: a compound gate CI could satisfy, in a file that
+ // happens to read an unrelated env var. This is the native-addon-bytes
+ // shape, and it must read as registrable.
+ expect(
+ requiresEnvOptIn(
+ "const tmp = process.env.RUNNER_TEMP\ndescribe.runIf(process.platform === 'win32' && hasAddon)('x', () => {})"
+ )
+ ).toBe(false)
+ // One hop through a const: the real shape of the ten listed suites.
+ expect(
+ requiresEnvOptIn(
+ "const distro = process.env.ORCA_TEST_WSL_DISTRO\ndescribe.runIf(process.platform === 'win32' && Boolean(distro))('x', () => {})"
+ )
+ ).toBe(true)
+ // Read inline in the conjunct: the other real shape.
+ expect(
+ requiresEnvOptIn(
+ "const RUN = process.platform === 'win32' && process.env.ORCA_REAL_X === '1'"
+ )
+ ).toBe(true)
+ })
+
+ it('requires the gate to be compound at all', () => {
+ // A bare `runIf(win32)` file -- which CI can run -- must never park as
+ // manual, however much `process.env` the file reads elsewhere.
+ expect(
+ requiresEnvOptIn(
+ "const t = process.env.CI\ndescribe.runIf(process.platform === 'win32')('x', () => {})"
+ )
+ ).toBe(false)
+ // The case that makes the `&&` in WIN32_CONJUNCT load-bearing rather than
+ // decorative: an env read on the SAME line as a bare gate. Drop the `&&`
+ // and this reads as manual, which is the parking hole reopened.
+ expect(
+ requiresEnvOptIn(
+ "describe.runIf(process.platform === 'win32')(`x ${process.env.ORCA_TAG}`, () => {})"
+ )
+ ).toBe(false)
+ })
+})
+
+describe('Windows runner detection', () => {
+ it('reads every runs-on spelling that could land on Windows', () => {
+ expect(couldRunOnWindows('windows-2022')).toBe(true)
+ // The spelling that would have slipped past an equality test.
+ expect(couldRunOnWindows('windows-latest')).toBe(true)
+ expect(couldRunOnWindows(['self-hosted', 'Windows', 'X64'])).toBe(true)
+ expect(couldRunOnWindows({ group: 'windows-runners', labels: ['x64'] })).toBe(true)
+ // Unresolvable from the file, so it fails closed rather than reading as safe.
+ expect(couldRunOnWindows('${{ matrix.os }}')).toBe(true)
+ expect(couldRunOnWindows('ubuntu-latest')).toBe(false)
+ expect(couldRunOnWindows(['self-hosted', 'linux'])).toBe(false)
+ expect(couldRunOnWindows(undefined)).toBe(false)
+ })
+})
+
+describe('Windows-gate detection', () => {
+ // Each positive is paired with the near-miss it must reject. The pairs are
+ // written from the shapes that exist in the repo, not from the regexes above.
+ const cases = [
+ [
+ 'describe.runIf equality',
+ "describe.runIf(process.platform === 'win32')('x', () => {})",
+ "describe.runIf(process.platform !== 'win32')('x', () => {})"
+ ],
+ [
+ 'describe.skipIf inequality',
+ "describe.skipIf(process.platform !== 'win32')('x', () => {})",
+ "describe.skipIf(process.platform === 'win32')('x', () => {})"
+ ],
+ [
+ 'ternary describe alias',
+ "const d = process.platform === 'win32' ? describe : describe.skip",
+ "const d = process.platform === 'win32' ? describe.skip : describe"
+ ],
+ [
+ 'inverted ternary describe alias',
+ "const d = process.platform !== 'win32' ? describe.skip : describe",
+ "const d = process.platform !== 'win32' ? describe : describe.skip"
+ ],
+ [
+ 'local isWindows flag',
+ "const isWindows = process.platform === 'win32'\ndescribe.skipIf(!isWindows)('x', () => {})",
+ "const isWindows = process.platform === 'win32'\ndescribe.skipIf(isWindows)('x', () => {})"
+ ],
+ [
+ 'local isWindows flag, runIf',
+ "const isWindows = process.platform === 'win32'\ndescribe.runIf(isWindows)('x', () => {})",
+ "const isWindows = process.platform === 'win32'\ndescribe.runIf(!isWindows)('x', () => {})"
+ ],
+ [
+ // The blocking miss: a second conjunct made the gate invisible.
+ 'compound gate with a second conjunct',
+ "describe.runIf(process.platform === 'win32' && Boolean(distro))('x', () => {})",
+ "describe.runIf(process.platform === 'win32' || Boolean(distro))('x', () => {})"
+ ],
+ [
+ 'compound gate behind a named flag assigned on the next line',
+ "const RUN_REAL =\n process.platform === 'win32' && process.env.X === '1'\ndescribe.runIf(RUN_REAL)('x', () => {})",
+ "const RUN_REAL =\n process.platform !== 'win32' && process.env.X === '1'\ndescribe.runIf(RUN_REAL)('x', () => {})"
+ ],
+ [
+ 'named flag driving a ternary suite alias',
+ "const enabled = process.platform === 'win32' && process.env.X === '1'\nconst d = enabled ? describe : describe.skip",
+ "const enabled = process.platform === 'win32' && process.env.X === '1'\nconst d = enabled ? describe.skip : describe"
+ ],
+ [
+ 'compound skipIf widened with ||',
+ "describe.skipIf(process.platform !== 'win32' || !hasAddon)('x', () => {})",
+ "describe.skipIf(process.platform !== 'win32' && !hasAddon)('x', () => {})"
+ ],
+ [
+ 'double-quoted and loosely spaced',
+ 'describe . runIf ( process.platform === "win32" )("x", () => {})',
+ 'describe . runIf ( process.platform === "darwin" )("x", () => {})'
+ ]
+ ]
+
+ for (const [label, gated, nearMiss] of cases) {
+ it(`detects ${label} and rejects its near miss`, () => {
+ expect(isWindows32GatedTestFile('src/x/sample.test.ts', gated)).toBe(true)
+ expect(isWindows32GatedTestFile('src/x/sample.test.ts', nearMiss)).toBe(false)
+ })
+ }
+
+ it('detects the .win32 filename with no gate expression at all', () => {
+ expect(isWindows32GatedTestFile('src/x/sample.win32.test.ts', 'describe("x", () => {})')).toBe(
+ true
+ )
+ // Near miss: `.win32.ts` is production source, not a test the lane can run.
+ expect(isWindows32GatedTestFile('src/x/sample.win32.ts', 'export const x = 1')).toBe(false)
+ })
+
+ it('does not read a flag whose name merely starts the same', () => {
+ // Without word boundaries `isWindows` would swallow `isWindowsHost`.
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "const isWindows = process.platform === 'win32'\ndescribe.runIf(isWindowsHost)('x', () => {})"
+ )
+ ).toBe(false)
+ })
+
+ it('rejects the documented blind spots rather than half-detecting them', () => {
+ // it-level gate inside a cross-platform suite: out of scope by design.
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "describe('x', () => { it.skipIf(process.platform !== 'win32')('y', () => {}) })"
+ )
+ ).toBe(false)
+ // A platform branch inside a test body is not a gate.
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "it('x', () => { if (process.platform === 'win32') { return } })"
+ )
+ ).toBe(false)
+ // An imported flag: the assignment is not in this file, so polarity is unknowable.
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "import { isWindows } from './f'\ndescribe.runIf(isWindows)('x', () => {})"
+ )
+ ).toBe(false)
+ })
+
+ it('does not treat a widening conjunct behind a named flag as Windows-only', () => {
+ // `!== 'win32' && x` skips only when BOTH hold, so the suite runs on
+ // Windows and on POSIX when `x` is false. The literal form is rejected by
+ // the `||` pair above; this is the same condition routed through a flag,
+ // which is where the shared lookahead used to flip the answer.
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "const p = process.platform !== 'win32' && Boolean(x)\ndescribe.skipIf(p)('x', () => {})"
+ )
+ ).toBe(false)
+ // The narrowing direction still counts: `=== 'win32' && x` is Windows-only.
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "const p = process.platform === 'win32' && Boolean(x)\ndescribe.runIf(p)('x', () => {})"
+ )
+ ).toBe(true)
+ })
+
+ it('ignores a gate that only appears in prose', () => {
+ expect(
+ isWindows32GatedTestFile(
+ 'src/x/sample.test.ts',
+ "// describe.runIf(process.platform === 'win32')\ndescribe('x', () => {})"
+ )
+ ).toBe(false)
+ })
+})
diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs
new file mode 100644
index 00000000000..a8c2cb3f4e7
--- /dev/null
+++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs
@@ -0,0 +1,129 @@
+import { readdirSync, readFileSync } from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Guard the one idiom that keeps re-killing Windows tooling.
+ *
+ * Node >= 20 refuses to spawn a Windows batch shim without `shell: true` (the
+ * CVE-2024-27980 mitigation), so `spawnSync('pnpm.cmd', …)` throws EINVAL
+ * before the command runs at all. On Windows that reads as a broken toolchain
+ * rather than a failing check, so the failure gets shrugged off — which is
+ * exactly how `check:code-quality:changed` ran dead for months.
+ *
+ * `src/` has its own chokepoint (runProcess) and its own ratchet. These trees
+ * are plain `.mjs` run by bare `node`, outside that module boundary, so they
+ * need this narrower one: a batch-shim command literal may not appear in a new
+ * script. The list only shrinks. Resolve the real executable instead —
+ * `oxlint-cli-invocation.mjs` and `windows-process-tree-gyp-rebuild.mjs` show
+ * the shape.
+ *
+ * Deliberately a text match on any `.cmd`/`.bat` literal, not on a list of
+ * runner names: these trees already spawn vitest, playwright, electron-builder
+ * and tsc, and the next offender is as likely to be one of those as it is to be
+ * pnpm. A literal is all a copy-paste carries.
+ *
+ * Two shapes this does not catch, both accepted. A shim assembled in a template
+ * literal, and a drive-lettered path — 'C:\tools\pnpm.cmd' — since a colon is
+ * not in the class. Real code builds those with path.join, whose 'pnpm.cmd'
+ * argument is caught. Also note codeText only drops lines that BEGIN with a
+ * comment marker, so a trailing `// 'pnpm.cmd'` false-positives; that fails
+ * closed. All of which is the ceiling of a text ratchet, and the reason `src/`
+ * gets a real chokepoint instead.
+ */
+const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i
+
+const SCANNED_ROOTS = ['config/scripts', 'tests/tools']
+
+/** Scripts that still name a batch shim, held as data so it reads as the list it is. */
+const WINDOWS_SHIM_SPAWN_ALLOWLIST = [
+ // Owns the pnpm invocation decision for every other script.
+ 'config/scripts/pnpm-cli-invocation.mjs',
+ 'config/scripts/pnpm-cli-invocation.test.mjs',
+ // Write or assert on shim files rather than spawning one.
+ 'config/scripts/dev-cli-terminal-wrapper.mjs',
+ 'config/scripts/dev-cli-terminal-wrapper.test.mjs',
+ 'config/scripts/electron-builder-config.test.mjs',
+ 'config/scripts/ensure-native-runtime.test.mjs',
+ 'config/scripts/live-remote-freeze-rpc.mjs',
+ 'config/scripts/remote-agent-session-authority-repro.mjs',
+ // Platform-local build paths; the win32 branch is dead code on both.
+ 'config/scripts/build-mac-local.mjs',
+ 'config/scripts/build-linux-local.mjs',
+ 'config/scripts/build-linux-local.test.mjs',
+ // Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI.
+ 'config/scripts/build-orcad-prebuilds.mjs',
+ 'config/scripts/run-ai-vault-typing-bench.mjs',
+ 'config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs',
+ 'config/scripts/run-local-ssh-browser-routing-e2e.mjs',
+ 'config/scripts/run-multi-client-navigation-e2e.mjs',
+ 'config/scripts/run-multi-workspace-typing-bench.mjs',
+ 'config/scripts/run-nested-runtime-ssh-e2e.mjs',
+ 'config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs',
+ 'config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs',
+ 'config/scripts/run-ssh-docker-e2e.mjs',
+ 'config/scripts/run-ssh-docker-perf-e2e.mjs',
+ 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs',
+ 'config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs',
+ 'config/scripts/run-ssh-staged-upload-reliability.mjs',
+ 'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
+ 'config/scripts/run-terminal-scale-perf-e2e.mjs',
+ // Routes its shim through an explicit `cmd.exe /d /s /c`, which is the correct form.
+ 'config/scripts/verify-skill-update-roundtrip.mjs',
+ 'tests/tools/benchmarks/startup-time-bench.mjs',
+ 'tests/tools/benchmarks/worktree-deletion-dev-bench.mjs',
+ 'tests/tools/repro-terminal-send-submit.mjs'
+]
+
+/** Drop comment-only lines so prose about the old idiom is not an offender. */
+function codeText(contents) {
+ return contents
+ .split('\n')
+ .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line))
+ .join('\n')
+}
+
+// Why recursive: a future config/scripts// would otherwise escape silently.
+function collectScripts(directory, repoRoot, found = []) {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const full = path.join(directory, entry.name)
+ if (entry.isDirectory()) {
+ if (entry.name !== 'node_modules') {
+ collectScripts(full, repoRoot, found)
+ }
+ continue
+ }
+ if (/\.[cm]?js$/.test(entry.name)) {
+ found.push(path.relative(repoRoot, full).split(path.sep).join('/'))
+ }
+ }
+ return found
+}
+
+describe('windows batch shim spawn boundary', () => {
+ const repoRoot = path.resolve(import.meta.dirname, '..', '..')
+ const scripts = SCANNED_ROOTS.flatMap((root) =>
+ collectScripts(path.join(repoRoot, root), repoRoot)
+ )
+ const offenders = scripts.filter((relativePath) =>
+ WINDOWS_SHIM_LITERAL.test(codeText(readFileSync(path.join(repoRoot, relativePath), 'utf8')))
+ )
+
+ it('scans a plausible number of scripts', () => {
+ // A broken root or extension filter would make the guard silently vacuous.
+ expect(scripts.length).toBeGreaterThan(100)
+ })
+
+ it('has no unlisted script naming a Windows batch shim', () => {
+ const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name))
+ expect(
+ unlisted,
+ 'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxlint-cli-invocation.mjs.'
+ ).toEqual([])
+ })
+
+ it('has no stale allowlist entry', () => {
+ const stale = WINDOWS_SHIM_SPAWN_ALLOWLIST.filter((name) => !offenders.includes(name))
+ expect(stale, 'Script no longer names a batch shim — delete the line.').toEqual([])
+ })
+})
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 93d556602f8..1b9600188f2 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -127,6 +127,8 @@
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
+ // The parity test keeps this import-free list aligned with COMMAND_SPECS.
+ "../src/main/startup/cli-command-names.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/sqlite/sync-database.ts",
"../src/main/win32-utils.ts"
diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json
index afe5e83024c..56253527c69 100644
--- a/config/tsconfig.tc.web.json
+++ b/config/tsconfig.tc.web.json
@@ -31,6 +31,7 @@
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl-running-distro-cache.ts",
"../src/main/wsl.ts",
+ "../src/main/wsl-interop-spawn-directory.ts",
"../src/main/persistence/applying-settings/ui-state-read.ts",
"../src/main/persistence/applying-settings/ui-state-update.ts",
"../src/main/persistence/applying-settings/ui-selection-normalization.ts",
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index 0bde5e2704a..0708d09d993 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-