Merge origin/main and fix Windows structured Codex review findings

This commit is contained in:
Merge Sim
2026-09-02 15:32:29 -07:00
1087 changed files with 58089 additions and 10374 deletions
+4 -8
View File
@@ -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
+37 -1
View File
@@ -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 }}
+3 -2
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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.
+2 -2
View File
@@ -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.
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />&nbsp;&nbsp;
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
- **Feedback &amp; 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
</p>
## Signed Builds
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
@@ -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"]
+84
View File
@@ -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
@@ -28,7 +28,6 @@ RUN apt-get update \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
@@ -22,16 +22,15 @@ RUN apt-get update \
libxkbcommon0 \
libxrandr2 \
libxss1 \
p7zip-full \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-signal-case.sh /usr/local/bin/run-signal-case
COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
@@ -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[*]})."
@@ -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 <command>'* ]]; 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
+55 -4
View File
@@ -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
+56 -5
View File
@@ -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) {
@@ -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
}
+211 -5
View File
@@ -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",
@@ -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 <napi.h>
#include <assert.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <thread>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
/* forkpty */
/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */
#if defined(__linux__)
#include <pty.h>
#elif defined(__APPLE__)
#include <util.h>
#elif defined(__FreeBSD__)
#include <libutil.h>
#include <termios.h>
#elif defined(__OpenBSD__)
#include <util.h>
#include <termios.h>
#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 <stdio.h>
#include <stdint.h>
#elif defined(__APPLE__)
#include <libproc.h>
#include <os/availability.h>
#include <paths.h>
#include <spawn.h>
#include <sys/event.h>
#include <sys/sysctl.h>
#include <termios.h>
#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<uintptr_t>(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<Napi::String>();
// args
Napi::Array argv_ = info[1].As<Napi::Array>();
// env
Napi::Array env_ = info[2].As<Napi::Array>();
int envc = env_.Length();
std::unique_ptr<char *, DelBuf> 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<Napi::String>();
env[i] = strdup(pair.c_str());
}
// cwd
std::string cwd_ = info[3].As<Napi::String>();
// size
struct winsize winp;
winp.ws_col = info[4].As<Napi::Number>().Int32Value();
winp.ws_row = info[5].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
#if !defined(__APPLE__)
// uid / gid
int uid = info[6].As<Napi::Number>().Int32Value();
int gid = info[7].As<Napi::Number>().Int32Value();
#endif
// termios
struct termios t = termios();
struct termios *term = &t;
term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT;
if (info[8].As<Napi::Boolean>().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<Napi::String>();
pid_t pid;
int master;
#if defined(__APPLE__)
int argc = argv_.Length();
int argl = argc + 4;
std::unique_ptr<char *, DelBuf> 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<Napi::String>();
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<char *, DelBuf> 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<Napi::String>();
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<termios*>(term), static_cast<winsize*>(&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<Napi::Function>();
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<Napi::Number>().Int32Value();
winp.ws_row = info[1].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
// pty
int master, slave;
int ret = openpty(&master, &slave, nullptr, NULL, static_cast<winsize*>(&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<Napi::Number>().Int32Value();
struct winsize winp;
winp.ws_col = info[1].As<Napi::Number>().Int32Value();
winp.ws_row = info[2].As<Napi::Number>().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<Napi::Number>().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<Napi::Number>().Int32Value();
std::string tty_ = info[1].As<Napi::String>();
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 <nicm@users.sourceforge.net>
// Copyright (c) 2009 Joshua Elsasser <josh@elsasser.org>
// Copyright (c) 2009 Todd Carson <toc@daybefore.net>
//
// 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)
+65
View File
@@ -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) })
}
+85
View File
@@ -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')
})
})
+4 -3
View File
@@ -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}`)
+11
View File
@@ -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({
@@ -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
+17 -3
View File
@@ -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) {
@@ -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')
}
)
})
+44 -293
View File
@@ -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 <socket>"); 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()
}
})
})
})
@@ -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 <socket>"); 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 })
}
}
)
})
@@ -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)
)
})
})
+5 -4
View File
@@ -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) {
@@ -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 ;;')
})
})
@@ -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
}
@@ -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)
}
@@ -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')
})
})
+23
View File
@@ -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)]
}
}
@@ -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')
})
})
@@ -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()
+7
View File
@@ -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'
@@ -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,
@@ -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',
+1
View File
@@ -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',
@@ -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')
@@ -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)
+264
View File
@@ -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 <command>',
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=<name>`, 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)
}
+2
View File
@@ -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',
@@ -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)
})
})
@@ -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')
@@ -0,0 +1,260 @@
const { closeSync, fstatSync, openSync, readSync } = require('node:fs')
const { basename } = require('node:path')
const EXPECTED_ARCHITECTURE_BY_FILENAME = new Map([
['orca-linux.AppImage', 'x64'],
['orca-linux-arm64.AppImage', 'arm64']
])
const APPIMAGE_MAGIC = Buffer.from([0x41, 0x49, 0x02])
const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime')
const TARGET_ARCHITECTURE_BY_ENUM = new Map([
[1, 'x64'],
[3, 'arm64']
])
const RUNTIME_ARCHITECTURE_BY_MACHINE = new Map([
[0x3e, 'x64'],
[0xb7, 'arm64']
])
const ELF_HEADER_BYTES = 64
const PROGRAM_HEADER_BYTES = 56
const DYNAMIC_ENTRY_BYTES = 16
const MAX_PROGRAM_HEADERS = 128
const MAX_LOAD_BYTES = 16 * 1024 * 1024
const MAX_DYNAMIC_BYTES = 1024 * 1024
function verifyStaticAppImagePackage(filePath, targetArch) {
const filename = basename(filePath)
const filenameArchitecture = EXPECTED_ARCHITECTURE_BY_FILENAME.get(filename)
if (!filenameArchitecture) {
invalid(
filename,
`unsupported artifact name; expected ${[...EXPECTED_ARCHITECTURE_BY_FILENAME.keys()].join(' or ')}`
)
}
const targetArchitecture = normalizeTargetArchitecture(targetArch, filename)
if (filenameArchitecture !== targetArchitecture) {
invalid(
filename,
`artifact filename targets ${filenameArchitecture}, but electron-builder target is ${targetArchitecture}`
)
}
const descriptor = openSync(filePath, 'r')
try {
const stats = fstatSync(descriptor, { bigint: true })
if (process.platform !== 'win32' && (stats.mode & 0o111n) === 0n) {
invalid(filename, 'artifact is not executable')
}
const fileSize = stats.size
const header = readRange(
descriptor,
0n,
BigInt(ELF_HEADER_BYTES),
fileSize,
filename,
'ELF header'
)
const { entry, machine } = verifyElfHeader(header, filename)
const runtimeArchitecture = RUNTIME_ARCHITECTURE_BY_MACHINE.get(machine)
if (runtimeArchitecture !== targetArchitecture) {
invalid(
filename,
`runtime architecture ${runtimeArchitecture ?? `machine 0x${machine.toString(16)}`} does not match electron-builder target ${targetArchitecture}`
)
}
const programHeaderOffset = header.readBigUInt64LE(32)
const programHeaderSize = header.readUInt16LE(54)
const programHeaderCount = header.readUInt16LE(56)
if (programHeaderSize !== PROGRAM_HEADER_BYTES) {
invalid(filename, `unexpected ELF program-header size ${programHeaderSize}`)
}
if (programHeaderCount === 0 || programHeaderCount > MAX_PROGRAM_HEADERS) {
invalid(filename, `invalid ELF program-header count ${programHeaderCount}`)
}
const tableSize = BigInt(programHeaderSize * programHeaderCount)
const table = readRange(
descriptor,
programHeaderOffset,
tableSize,
fileSize,
filename,
'ELF program-header table'
)
const segments = parseProgramHeaders(table, programHeaderSize)
verifySegments(descriptor, segments, fileSize, filename, entry)
} finally {
closeSync(descriptor)
}
}
function verifyElfHeader(header, filename) {
if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
invalid(filename, 'missing ELF magic')
}
if (header[4] !== 2 || header[5] !== 1 || header[6] !== 1) {
invalid(filename, 'runtime must be ELF64 little-endian version 1')
}
if (!header.subarray(8, 11).equals(APPIMAGE_MAGIC)) {
invalid(filename, 'missing type-2 AppImage marker')
}
if (header.readUInt16LE(16) !== 3) {
invalid(filename, 'runtime must be an ET_DYN static PIE')
}
const machine = header.readUInt16LE(18)
if (!RUNTIME_ARCHITECTURE_BY_MACHINE.has(machine)) {
invalid(filename, `unsupported ELF machine 0x${machine.toString(16)}`)
}
if (header.readUInt32LE(20) !== 1) {
invalid(filename, 'runtime has an unsupported ELF version')
}
if (header.readUInt16LE(52) !== ELF_HEADER_BYTES) {
invalid(filename, `unexpected ELF header size ${header.readUInt16LE(52)}`)
}
return { entry: header.readBigUInt64LE(24), machine }
}
function parseProgramHeaders(table, entrySize) {
const segments = []
for (let offset = 0; offset < table.length; offset += entrySize) {
segments.push({
type: table.readUInt32LE(offset),
flags: table.readUInt32LE(offset + 4),
offset: table.readBigUInt64LE(offset + 8),
virtualAddress: table.readBigUInt64LE(offset + 16),
fileSize: table.readBigUInt64LE(offset + 32),
memorySize: table.readBigUInt64LE(offset + 40)
})
}
return segments
}
function verifySegments(descriptor, segments, fileSize, filename, entry) {
if (segments.some((segment) => segment.type === 3)) {
invalid(filename, 'runtime contains PT_INTERP')
}
const loadSegments = segments.filter((segment) => segment.type === 1)
const totalLoadBytes = loadSegments.reduce((total, segment) => total + segment.fileSize, 0n)
if (loadSegments.length === 0 || totalLoadBytes > BigInt(MAX_LOAD_BYTES)) {
invalid(filename, `invalid or oversized PT_LOAD data (${totalLoadBytes} bytes)`)
}
if (
!loadSegments.some(
(segment) =>
segment.flags & 1 &&
entry >= segment.virtualAddress &&
entry - segment.virtualAddress < segment.memorySize
)
) {
invalid(filename, 'ELF entry point is outside an executable PT_LOAD segment')
}
let identifiesStaticRuntime = false
for (const segment of loadSegments) {
verifyFileBackedSegment(segment, fileSize, filename, 'PT_LOAD')
const data = readRange(
descriptor,
segment.offset,
segment.fileSize,
fileSize,
filename,
'PT_LOAD data'
)
identifiesStaticRuntime ||= data.includes(RUNTIME_SOURCE)
}
if (!identifiesStaticRuntime) {
invalid(filename, `runtime does not identify ${RUNTIME_SOURCE.toString()}`)
}
for (const segment of segments.filter((entry) => entry.type === 2)) {
verifyDynamicSegment(descriptor, segment, fileSize, filename)
}
}
function normalizeTargetArchitecture(targetArch, filename) {
const architecture =
typeof targetArch === 'number' ? TARGET_ARCHITECTURE_BY_ENUM.get(targetArch) : targetArch
if (architecture !== 'x64' && architecture !== 'arm64') {
invalid(filename, `unsupported electron-builder target architecture ${String(targetArch)}`)
}
return architecture
}
function verifyFileBackedSegment(segment, fileSize, filename, label) {
if (segment.memorySize < segment.fileSize) {
invalid(filename, `${label} memory size is smaller than its file size`)
}
verifyRange(segment.offset, segment.fileSize, fileSize, filename, label)
}
function verifyDynamicSegment(descriptor, segment, fileSize, filename) {
verifyFileBackedSegment(segment, fileSize, filename, 'PT_DYNAMIC')
if (
segment.fileSize === 0n ||
segment.fileSize > BigInt(MAX_DYNAMIC_BYTES) ||
segment.fileSize % BigInt(DYNAMIC_ENTRY_BYTES) !== 0n
) {
invalid(filename, `invalid PT_DYNAMIC size ${segment.fileSize}`)
}
const dynamic = readRange(
descriptor,
segment.offset,
segment.fileSize,
fileSize,
filename,
'PT_DYNAMIC data'
)
let terminated = false
for (let offset = 0; offset < dynamic.length; offset += DYNAMIC_ENTRY_BYTES) {
const tag = dynamic.readBigInt64LE(offset)
if (tag === 0n) {
terminated = true
break
}
if (tag === 1n) {
invalid(filename, 'runtime contains a DT_NEEDED dependency')
}
}
if (!terminated) {
invalid(filename, 'PT_DYNAMIC is missing DT_NULL')
}
}
function readRange(descriptor, offset, size, fileSize, filename, label) {
verifyRange(offset, size, fileSize, filename, label)
const buffer = Buffer.alloc(Number(size))
let bytesRead = 0
while (bytesRead < buffer.length) {
const count = readSync(
descriptor,
buffer,
bytesRead,
buffer.length - bytesRead,
Number(offset) + bytesRead
)
if (count === 0) {
throw new Error(`Unable to read complete ${label}`)
}
bytesRead += count
}
return buffer
}
function verifyRange(offset, size, fileSize, filename, label) {
const maxSafeOffset = BigInt(Number.MAX_SAFE_INTEGER)
if (
offset > fileSize ||
size > fileSize - offset ||
offset > maxSafeOffset ||
size > maxSafeOffset - offset
) {
invalid(filename, `${label} is outside the artifact`)
}
}
function invalid(filename, reason) {
throw new Error(`Invalid static AppImage ${filename}: ${reason}`)
}
module.exports = { verifyStaticAppImagePackage }
@@ -0,0 +1,225 @@
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { verifyStaticAppImagePackage } = require('./static-appimage-package-contract.cjs')
const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime')
const LOAD_HEADER = 64
const DYNAMIC_HEADER = 120
const DYNAMIC_OFFSET = 320
const FIXTURE_BYTES = 384
describe('static AppImage package contract', () => {
it.each([
['orca-linux.AppImage', 0x3e, 1],
['orca-linux-arm64.AppImage', 0xb7, 'arm64']
])('accepts a dependency-free type-2 %s runtime', async (filename, machine, targetArch) => {
await withFixture(filename, createRuntime({ machine }), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).not.toThrow()
})
})
it.each([
['generic filename for an arm64 runtime and target', 'orca-linux.AppImage', 0xb7, 3],
['arm64 filename for an x64 runtime and target', 'orca-linux-arm64.AppImage', 0x3e, 1],
['generic x64 runtime for an arm64 target', 'orca-linux.AppImage', 0x3e, 3],
['generic arm64 runtime for an x64 target', 'orca-linux.AppImage', 0xb7, 1],
['arm64 artifact filename for an x64 target', 'orca-linux-arm64.AppImage', 0xb7, 1],
['x64 runtime under an arm64 artifact filename', 'orca-linux-arm64.AppImage', 0x3e, 3]
])('rejects %s', async (_label, filename, machine, targetArch) => {
await withFixture(filename, createRuntime({ machine }), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/architecture|target/)
})
})
it.each([undefined, 0, 'ia32'])(
'rejects unsupported target architecture %s',
async (targetArch) => {
await withFixture('orca-linux.AppImage', createRuntime(), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/target architecture/)
})
}
)
it('accepts PT_DYNAMIC relocation metadata without dependencies', async () => {
const runtime = createRuntime()
runtime.writeBigInt64LE(7n, DYNAMIC_OFFSET)
await withFixture('orca-linux.AppImage', runtime, (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow()
})
})
it('does not scan the appended AppImage payload as outer ELF data', async () => {
const payload = Buffer.concat([RUNTIME_SOURCE, Buffer.alloc(16, 1)])
await withFixture('orca-linux.AppImage', Buffer.concat([createRuntime(), payload]), (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow()
})
const unidentifiedRuntime = createRuntime()
unidentifiedRuntime.fill(0, 192, 192 + RUNTIME_SOURCE.length)
await withFixture(
'orca-linux.AppImage',
Buffer.concat([unidentifiedRuntime, payload]),
(path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/does not identify/)
}
)
})
it('rejects artifact names outside the release contract before reading them', () => {
expect(() => verifyStaticAppImagePackage('/missing/orca-preview.AppImage')).toThrow(
'unsupported artifact name'
)
})
it.skipIf(process.platform === 'win32')(
'rejects a readable but non-executable AppImage',
async () => {
await withFixture(
'orca-linux.AppImage',
createRuntime(),
(path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/not executable/)
},
{ mode: 0o644 }
)
}
)
it.each([
[
'non-ELF64 runtimes',
(runtime) => {
runtime[4] = 1
},
/ELF64 little-endian/
],
[
'unsupported ELF versions',
(runtime) => runtime.writeUInt32LE(2, 20),
/unsupported ELF version/
],
[
'non-type-2 AppImages',
(runtime) => {
runtime[10] = 1
},
/type-2 AppImage marker/
],
['non-PIE runtimes', (runtime) => runtime.writeUInt16LE(2, 16), /ET_DYN static PIE/],
[
'unsupported architectures',
(runtime) => runtime.writeUInt16LE(3, 18),
/unsupported ELF machine/
],
['dynamic loaders', (runtime) => runtime.writeUInt32LE(3, DYNAMIC_HEADER), /PT_INTERP/],
[
'shared-library dependencies',
(runtime) => runtime.writeBigInt64LE(1n, DYNAMIC_OFFSET),
/DT_NEEDED/
],
[
'unidentified runtimes',
(runtime) => runtime.fill(0, 192, 192 + RUNTIME_SOURCE.length),
/does not identify/
],
[
'out-of-bounds load segments',
(runtime) => {
runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 32)
runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 40)
},
/outside the artifact/
],
[
'oversized load claims',
(runtime) => {
runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 32)
runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 40)
},
/oversized PT_LOAD/
],
[
'non-executable entry segments',
(runtime) => runtime.writeUInt32LE(4, LOAD_HEADER + 4),
/executable PT_LOAD/
],
[
'entry points outside load segments',
(runtime) => runtime.writeBigUInt64LE(4096n, 24),
/entry point/
]
])('rejects %s', async (_label, mutate, expected) => {
const runtime = createRuntime()
mutate(runtime)
await withFixture('orca-linux.AppImage', runtime, (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(expected)
})
})
})
function createRuntime({ machine = 0x3e } = {}) {
const runtime = Buffer.alloc(FIXTURE_BYTES)
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(runtime)
Buffer.from([0x41, 0x49, 0x02]).copy(runtime, 8)
runtime.writeUInt16LE(3, 16)
runtime.writeUInt16LE(machine, 18)
runtime.writeUInt32LE(1, 20)
runtime.writeBigUInt64LE(0n, 24)
runtime.writeBigUInt64LE(64n, 32)
runtime.writeUInt16LE(64, 52)
runtime.writeUInt16LE(56, 54)
runtime.writeUInt16LE(2, 56)
writeProgramHeader(runtime, LOAD_HEADER, {
type: 1,
flags: 5,
offset: 0,
virtualAddress: 0,
size: FIXTURE_BYTES,
memorySize: FIXTURE_BYTES,
alignment: 4096
})
writeProgramHeader(runtime, DYNAMIC_HEADER, {
type: 2,
flags: 4,
offset: DYNAMIC_OFFSET,
virtualAddress: DYNAMIC_OFFSET,
size: 32,
memorySize: 32,
alignment: 8
})
RUNTIME_SOURCE.copy(runtime, 192)
return runtime
}
function writeProgramHeader(
runtime,
headerOffset,
{ type, flags, offset, virtualAddress, size, memorySize = size, alignment }
) {
runtime.writeUInt32LE(type, headerOffset)
runtime.writeUInt32LE(flags, headerOffset + 4)
runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 8)
runtime.writeBigUInt64LE(BigInt(virtualAddress), headerOffset + 16)
runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 24)
runtime.writeBigUInt64LE(BigInt(size), headerOffset + 32)
runtime.writeBigUInt64LE(BigInt(memorySize), headerOffset + 40)
runtime.writeBigUInt64LE(BigInt(alignment), headerOffset + 48)
}
async function withFixture(filename, contents, check, { mode = 0o755 } = {}) {
const root = await mkdtemp(join(tmpdir(), 'orca-static-appimage-contract-'))
try {
const path = join(root, filename)
await writeFile(path, contents)
await chmod(path, mode)
await check(path)
} finally {
await rm(root, { recursive: true, force: true })
}
}
+9 -10
View File
@@ -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 {
@@ -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,
@@ -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 })
})
})
@@ -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(<true only on win32>)`, `describe.skipIf(<false only on win32>)`
* - `const d = <true only on win32> ? describe : describe.skip`, and the
* `? describe.skip : describe` inversion
* where the condition is `process.platform === 'win32'` / `!== 'win32'`, a
* compound `<win32 check> && <anything>`, 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(<win32> || <x>)` and `skipIf(<not win32> && <x>)` 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)
})
})
@@ -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/<subdir>/ 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([])
})
})
+2
View File
@@ -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"
+1
View File
@@ -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",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 35m">
<title>downloads: 35m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 36m">
<title>downloads: 36m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">35m</text>
<text x="90" y="14">35m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">36m</text>
<text x="90" y="14">36m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 394 KiB

+2 -2
View File
@@ -243,9 +243,9 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
- **WeChat :** Scannez pour rejoindre le groupe WeChat 7 de la communauté Orca.
- **WeChat :** Scannez pour rejoindre le groupe WeChat 8 de la communauté Orca.
<img src="../assets/wechat-qr-group7.jpg" alt="QR code WeChat groupe 7 de la communauté Orca" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="QR code WeChat groupe 8 de la communauté Orca" width="160" />
- **Feedback &amp; idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
- **Confidentialité :** Voir la [doc confidentialité &amp; télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
+2 -2
View File
@@ -238,9 +238,9 @@ yay -S stably-orca-bin
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 8에 참여하세요.
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 커뮤니티 WeChat 그룹 8 QR 코드" width="160" />
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
+1 -2
View File
@@ -235,9 +235,8 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫码加入 Orca 社区微信第 7 群。如果第 7 群已满,请使用第 8 群。
- **微信:** 扫码加入 Orca 社区微信第 8 群。
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />&nbsp;&nbsp;
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
+4 -4
View File
@@ -44,14 +44,14 @@ authority.
### Placeholders That Fail Open
`GitCapabilityCache` records commands Git *rejects*. A `git log --format`
`GitCapabilityCache` records commands Git _rejects_. A `git log --format`
placeholder Git does not know is not rejected: Git echoes it verbatim and exits
zero, so there is no error to remember and no probe to cache. Ask for both forms
in one record and pick at parse time.
| Placeholder | Preferred behavior | Compatibility behavior |
| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
| Placeholder | Preferred behavior | Compatibility behavior |
| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
## Why Not `simple-git`
+62 -9
View File
@@ -8,7 +8,11 @@ Linux, the packaged AppImage still needs the libraries that Electron expects at
startup. Current Orca builds start Xvfb automatically for `orca serve` when no
`DISPLAY` is set, but Xvfb must be installed first. A separate D-Bus session is
not required. When `DISPLAY` is set, Orca uses that display instead of starting
a competing Xvfb process.
a competing Xvfb process, provided the display is usable: its socket must exist,
and if an X lock file is present it must name a running process. A `DISPLAY`
whose lock names a dead process is refused rather than replaced, and `orca serve`
exits — unset `DISPLAY` to let Orca start its own Xvfb. A socket published with
no lock at all (a container bind-mounting `/tmp/.X11-unix`, or WSLg) is accepted.
The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and
current Debian stable — anything with glibc 2.31 or newer (see
@@ -229,6 +233,10 @@ clients should use.
`KillMode=mixed` sends the graceful stop signal only to Orca's main process,
then retains systemd's cgroup-wide `SIGKILL` fallback if shutdown times out.
This lets Orca keep its owned Xvfb alive until Electron disconnects cleanly.
It does **not** preserve the detached terminal daemon: the daemon and its PTYs
remain in `orca-serve.service`'s cgroup and are killed when the stop completes.
Every `systemctl stop` or `restart` therefore ends live terminals and agent
processes, even though their persisted layout and terminal history remain.
Exit status `3` means another process already owns this userData profile, so
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
@@ -324,6 +332,14 @@ sudo systemctl enable --now orca-xvfb.service orca-serve.service
## CLI Install Note
The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing
the GNOME Orca screen reader. Desktop-managed terminals receive a
terminal-scoped bare-`orca` shim. A packaged headless `orca serve` also makes a
best-effort dispatcher at `$HOME/.local/bin/orca` for the service user's own
shell, so the Claude Teams launcher can resolve its bare command; it does not
replace another user's `orca`. From an ordinary shell outside that service
user's managed environment, substitute `orca-ide` for `orca` in commands below.
On a headless host, you do not need to open the desktop UI just to run the
server. Invoke the AppImage directly:
@@ -341,6 +357,21 @@ the command:
This disables a security boundary. Prefer a dedicated unprivileged service
user, especially when the listener is reachable beyond localhost.
The Linux CLI is named `orca-ide`, not `orca`, so it never shadows the GNOME
Orca screen reader at `/usr/bin/orca`. The `.deb` and `.rpm` packages put
`orca-ide` on `PATH` themselves at install time; with the AppImage it arrives
as `~/.local/bin/orca-ide` when the CLI is registered.
A packaged `orca serve` start also writes a bare `orca` into `~/.local/bin`
that execs the same launcher, which is why the skills commands below can be
typed as `orca`. It writes it while starting, so it is never the command that
starts the server — the first launch is `orca-ide serve`, or the AppImage
invoked directly as above. The write is best-effort: it is gated on a packaged
build, it is skipped when no bundled launcher resolves, and it is skipped when
a file Orca does not own already holds that name (ownership is a marker on the
second line of the file). A host that really does run the screen reader keeps
its own `orca`.
## Pairing troubleshooting
- A pairing offer is a capability containing a device credential and E2EE
@@ -376,7 +407,7 @@ at all — the built-in updater only runs in the desktop GUI, and no paired mobi
or web client can trigger it remotely. Upgrading is always a deliberate step:
replace the AppImage and restart the service.
Two facts make this safe and predictable:
Two facts make the persisted-state transition predictable:
- **State lives in the service user's home, not next to the binary.** Persisted
data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca`
@@ -388,15 +419,37 @@ Two facts make this safe and predictable:
state into the current schema and writes it back in the current shape, so a
forward upgrade needs no manual data step.
These guarantees do not preserve live processes. The service restart kills
every terminal and agent in its cgroup; an agent conversation may be resumable,
but its current process and any in-flight command are gone.
Immediately before stopping the service, obtain a fresh census as the service's
OS account and home. Use the installer's absolute launcher path so `sudo`'s
`secure_path` cannot hide a per-user registration:
`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`.
Replace both `orca` and `/home/orca` with the service account and home used by
your unit; for an extracted deployment, use its absolute `resources/bin/orca-ide`
launcher instead. Proceed only when the result is
untruncated, has an explicit `hostScope`, covers every execution host affected
by this service stop, and lists no terminals on those hosts. Every
`omittedHostIds` entry must be explicitly accounted for outside this service's
execution boundary. 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`, so defer the restart. Do not allow new work between that census
and the stop; Orca does not yet provide an atomic census-and-stop fence.
Rolling back is the case that needs care — see [Roll back](#roll-back).
### Record the version you deploy
Orca has no headless version command: there is no `--version` flag or `version`
subcommand, and `orca serve` prints only its endpoint. Choose a release tag
explicitly instead of following the `latest` URL, and record it next to the
binary so upgrades are auditable. The steps below keep that record in
`/opt/orca/VERSION`.
The bundled CLI launcher prints the Orca build with `orca-ide --version`. For an
extracted deployment, that launcher is
`squashfs-root/resources/bin/orca-ide`; deb/rpm installs and CLI registration put
it on `PATH`. Do not use `orca-linux.AppImage --version` for this audit because
Electron owns the direct binary's version flags and may report its own runtime
version. For an AppImage service, choose a release tag explicitly and record it
next to the binary. The steps below keep that record in `/opt/orca/VERSION`.
### Upgrade steps
@@ -877,8 +930,8 @@ refuse to run there and print the command to run on the machine you want.
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
- `Missing X server or $DISPLAY`: install `xvfb`, or start the managed Xvfb
service and set `DISPLAY=:99`.
- `Xvfb not found`: confirm `command -v Xvfb` and use that absolute path in the
systemd unit.
- `[serve] Xvfb failed to start` or `[serve] Could not start Xvfb`: confirm
`command -v Xvfb` and that it is on the service `PATH`.
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
environment.
- Chromium sandbox errors: confirm the service is running as the non-root
@@ -6,6 +6,14 @@ Packaging enforces this floor automatically; keep it in mind when adding or
upgrading native dependencies. (The optional speech feature is the one
exception — see below.)
## Local package build prerequisites
`pnpm run build:linux` produces AppImage, deb, and RPM artifacts. The RPM target
requires `rpmbuild` on `PATH`; install `rpm` on Ubuntu/Debian, `rpm-build` on
Fedora/RHEL, or `rpm` through Homebrew on macOS, then verify it with
`rpmbuild --version` before packaging. Cross-host builds have the same
requirement.
## Why this needs attention
A native module (`.node`) links against the glibc of the machine that compiled
+45 -21
View File
@@ -4,8 +4,6 @@
whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its
readiness payload actually proves.
Design background: `docs/design/shipping-orcad.html` §00c and §04.
## Two long-lived processes, not one
A deployment is **orcad** plus **the terminal daemon**.
@@ -14,18 +12,22 @@ A deployment is **orcad** plus **the terminal daemon**.
| ---------- | -------------------------------- | ------------------------------------- |
| Started by | the supervisor | orcad, detached |
| Owns | RPC, git, worktrees, persistence | every local PTY |
| Lifetime | one supervised run | **outlives orcad** |
| Lifetime | one supervised run | detached from orcad, not its service |
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
The daemon outliving orcad is the property the whole peer model is recommended for
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
restart, so a restart, an update or a rollback does not destroy running work. Everything
below exists to keep that true.
orcad detaches the daemon and calls `disconnectDaemon()`, never `shutdownDaemon()`. The
built-in remote deployment path stops only the recorded orcad PID, so the daemon and its PTYs
survive. The successor adopts the current endpoint and routes supported previous protocol
versions through legacy adapters. This makes a PID-scoped update, rollback or restart
non-destructive to live work.
**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never
`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's
`KillMode=control-group` — kills the daemon too and turns every restart back into data loss.
Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group.
Process detachment is not service isolation. A daemon forked by orcad, and every PTY it owns,
remain in the same systemd service cgroup. `KillMode=mixed` does **not** preserve them: it
sends the graceful stop signal only to the main process, then sends `SIGKILL` to every process
remaining in the cgroup when the stop timeout expires. `KillMode=control-group` is destructive
too. `KillMode=process` leaves service-owned processes unmanaged and is not a supported
preservation mechanism. Service-restart survival requires separately supervised cgroups; the
current deployment does not provide them.
## Bind policy
@@ -76,6 +78,25 @@ a live daemon makes worthwhile.
## Supervision
### Process-scoped and cgroup-wide stops
The built-in remote updater performs a PID-scoped stop and keeps the daemon's install version
pinned while it owns sessions. A combined-unit systemd stop or restart is different: it reaps
the daemon and every live terminal after the graceful window.
Before a cgroup-wide stop, obtain a fresh `orca-ide terminal list --json` result using the same OS
account and home as the daemon. Invoke the installer's absolute launcher path so `sudo`'s
`secure_path` cannot hide a per-user registration (for example,
`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`). Replace both `orca` and
`/home/orca` with the service account and home used by the unit; an extracted deployment may use
its absolute `resources/bin/orca-ide` launcher instead. 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. Every
`omittedHostIds` entry must be explicitly accounted for outside the target service's execution
boundary. 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`: defer the stop. Do
not admit new work after the census. Orca does not yet provide an atomic census-and-stop fence.
### Who supervises orcad
An external supervisor (systemd, launchd, a process manager). orcad conforms to it:
@@ -127,11 +148,11 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
### Decommissioning
The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with
zero Orca processes. A daemon that has been adopted stays resident after its runtime
disconnects — that is what makes the next start a reattach rather than a cold restore. To
retire a host completely, stop orcad and then stop the daemon named by
`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale.
After a PID-scoped stop, an adopted daemon stays resident so the next orcad can reattach.
A combined-unit systemd stop kills it instead. To retire a process-scoped deployment, apply
the census rule above, stop orcad, then stop the daemon named by `health.terminalDaemon.pid`.
Only report it `exited` after verification on the execution host; loss of contact is
`unverifiable`.
## Health
@@ -145,7 +166,8 @@ nodeVersion / nodeAbi process.versions.node / .modules — the ABI native add
platform / arch / pid
terminalDaemon:
state live | degraded | absent
ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart
ownsFreshSessions whether NEW terminals are daemon-owned; this supports PID-scoped
restart recovery, not supervisor or service-cgroup isolation
pid the live daemon's pid, from its own PID record
buildVersion the build the LIVE daemon was forked from (may legitimately predate
this orcad after an update — reporting orcad's version for both would
@@ -179,11 +201,13 @@ Named here so nothing reads as implemented that is not:
- **A continuous health endpoint.** `health` is published once, in the readiness payload. A
supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same
`collectOrcadHealth()`; that surface does not exist yet.
- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy
(plan item 5), which owns libc detection; there is no honest value to publish until then.
- **`degradations[]`.** Plan item 2's contract, not this one.
- **Systemd-isolated daemon supervision.** orcad and its daemon currently share one service
cgroup, so a combined-unit stop cannot preserve live terminals.
- **libc slot.** There is no honest health value to publish until native libc detection owns
it.
- **`degradations[]`.** The readiness contract does not publish this collection yet.
- **Credential administration** (list / revoke / rotate devices, expiring pending offers,
structured security logging) — §04, not delivered here.
structured security logging).
- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on
conflict.
- **Reconciling `webClientUrl` with reachability** under the loopback default.
+9 -1
View File
@@ -40,6 +40,14 @@ Two ways remote work _can_ actually stop:
Reconnect re-attaches to the same live PTYs and replays a bounded buffer (`REPLAY_BUFFER_MAX`, a 102,400-code-unit tail). Output beyond that while you were away is lost to the client even though the process was never interrupted: **the transcript is truncated; the work stays `live`.**
## Updating Orca strands relay-backed terminals
There is a third outcome that is neither of the two above, and the vocabulary matters: the work does not stop, it becomes permanently unreachable.
The relay's install directory — and therefore its socket path — is namespaced by a content hash of the relay bundle (`computeRemoteRelayDir` in `src/main/ssh/ssh-relay-versioned-install.ts`, consumed by `resolveRemoteInstallState` in `src/main/ssh/ssh-relay-deploy.ts`), and the daemon refuses any client whose bundle hash differs (`handleDaemonHandshakeFrame` in `src/relay/relay-handshake.ts`, exit `EXIT_CODE_VERSION_MISMATCH` 42). Two builds whose relay protocol is byte-identical still refuse each other. So the first reconnect after an app update deploys a new relay at a path the incumbent was never listening on, and cannot reach it even in principle. Every PTY the incumbent owns is `unverifiable` — running, unreachable, and never `exited`. The client's leases are attempted against a relay that never minted their ids, expired on the not-found answer (`handlePtyReattachFailure` in `src/main/ssh/ssh-relay-session.ts`), and the pane falls back to a cold-restore agent resume — or to a bare shell when no resumable provider session was captured for it. The old relay keeps its directory pinned against GC, because its socket really is live (`hasLiveRelaySocket` in `src/main/ssh/remote-install-gc.ts`). See #13852.
The peer model does not have this failure, and that is the concrete reason behind "One host, one model" below. The daemon's endpoint is namespaced by a **semantic protocol version** rather than a build (`daemon-v<N>.sock`, from `getDaemonSocketPath` in `src/main/daemon/daemon-spawner.ts`), every earlier protocol version stays attachable (`PROTOCOL_VERSION` in `src/main/daemon/daemon-protocol-version.ts`), and a daemon holding live sessions is preserved across a version change instead of replaced (`shouldPreserveDaemonWithLiveSessions` in `src/main/daemon/daemon-replacement-preflight.ts`).
## Control plane
On an SSH host, `orca` is a shim (`~/.orca-relay/bin/orca`) that proxies **back to the client's runtime** over the relay socket. Your repository, processes, and files remain remote — only the control plane is on the client. This is correct for an SSH target, but it has a consequence worth stating plainly:
@@ -74,4 +82,4 @@ A listing is only evidence about the hosts it actually covered. When a result do
An SSH host and a paired runtime (`orca environment`) imply opposite boundaries: the first is a dumb execution host driven by your client, the second is a peer that owns its own control plane. Registering the same machine both ways splits its worktrees across two identities, makes `terminal list` return different sets depending on `--environment`, and reliably confuses both humans and agents. Pick one per machine.
For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs stay `live` across a normal runtime restart so the runtime can reattach; an explicit daemon shutdown can still make them `exited`. Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run.
For work that must continue while you are offline, use the peer/headless-runtime model on the remote host instead of the direct-SSH model. Its control plane is host-local, and its daemon-backed PTYs can stay `live` across a PID-scoped runtime restart so the runtime can reattach. A service manager that reaps the runtime's cgroup, or an explicit daemon shutdown, makes them `exited`; see [Running orcad](./orcad-operations.md#process-scoped-and-cgroup-wide-stops). Do not register the same machine through both models. A detached agent process outside Orca can also survive a control-plane outage, but it has no stdin, so its instructions cannot be amended mid-run.
+427
View File
@@ -0,0 +1,427 @@
# Windows EDR signal surface
Orca's Windows process tree is shaped like the thing behavioural EDR is built to
find. An enterprise Windows 11 / Intune tenant opened **six Microsoft Defender
for Endpoint incidents against Orca 1.4.192 in eight days**. All six fired as
active incidents and stayed open; three closed only because a human classified
them by hand in the portal. Defender never downgraded or closed one on its own.
None were signature hits. Every one was behavioural process-tree scoring, and
two escalated to multi-stage incidents carrying ATT&CK tactic mappings
(Execution, Collection).
The framing this document keeps throughout, because both halves matter:
> **Defender is not malfunctioning. It is describing the code accurately.** Orca
> really does copy its own signed image under a different name, really does read
> every process's memory on a timer, really does run base64-encoded PowerShell
> with the execution policy bypassed, and really does take screenshots and
> synthesise input from a runtime-compiled assembly. Each of those is a
> deliberate engineering choice with issue history behind it. The problem is not
> that the capabilities are illegitimate — it is that their **behavioural
> signature overlaps with attack techniques**, and an EDR scoring behaviour
> cannot see the difference.
Do not read this as a bug report against Defender, and do not read it as a claim
that Orca is malware. It is a map of which of our behaviours are legible to an
EDR as attack-technique-shaped, why each one exists, and what engineers and
administrators can do about it.
## What the tenant actually saw
Four independent evidence clusters, from six incidents:
| Cluster | Incidents | Evidence |
| ----------------- | --------- | -------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| **Spawn** | all six | `Orca.exe` → `orca-terminal-daemon.exe` → `powershell.exe` / `pwsh.exe` / `cmd.exe` / `reg.exe` → `claude.exe`, `gh.exe`, `codex.cmd` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
Incident E is the one to look at hardest: 5 alerts, 37 evidence items, ATT&CK
**Execution + Collection**, and a description reading _"Screenshots were taken
unexpectedly on this device… Screen capture code was found in a script launched
by powershell.exe."_ Incident F added _"suspicious MSIL code"_, from the
`Add-Type -TypeDefinition` that recompiles inline C# P/Invoke on every
operation.
In the update cluster the uninstaller is genuinely `NotSigned`, while `Orca.exe`
and `orca-terminal-daemon.exe` report `Valid CN=SignPath Foundation`.
## The behaviours, and why each one exists
### The daemon runs from a renamed copy of our own image
`src/main/daemon/daemon-host-relocation.ts` copies the Electron runtime into
`%LOCALAPPDATA%\Orca\daemon-host\<version>\` and renames `Orca.exe` to
`orca-terminal-daemon.exe`. The comment on `DAEMON_HOST_EXE_NAME` states the
reason without varnish: _"so the NSIS updater's `taskkill /IM Orca.exe` can't
match it."_
It exists because the NSIS installer deletes the old install directory and force-
kills every process imaged under it. Without relocation, an auto-update kills the
terminal daemon and every live terminal with it. The copy is a run-as-node
`Orca.exe` rather than `node.exe` so there is no console flash and asar still
resolves; `config/nsis/daemon-host-uninstall.nsh` reaps it on a real uninstall
(guarded by `${isUpdated}` so an update's `uninstallOldVersion` never fires it).
**How an EDR reads it: MITRE T1036, masquerading.** A signed executable copied
out of the install directory into `%LOCALAPPDATA%` under a different name, which
then spawns shells, matches the textbook description closely enough that no
behavioural engine can be expected to score it low.
### Every process gets a handle, on a timer
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under
**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid
and name come out of the snapshot itself and open nothing. `CommandLine` is what
opens a handle: the addon calls `GetProcessCommandLine` per process, which opens
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three
`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored
`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches).
`Memory` is retired as of this change, and that is a real reduction: it made
`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION |
PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose
result no caller read (`src/process.cc:47-63`). Dropping it halves the handles
opened per snapshot. It does not remove the remote memory read, because the
command line still performs one.
It exists because seven independent readers used to fork `powershell.exe` for a
`Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell
Transcription policy recorded **~289 GB across 1.4 million files** because a scan
ran every ~2 seconds (#15209); a Group Policy or AV block turned a query into
"unavailable", which callers read as "no evidence", which is how a PTY tree
survived its own teardown (#9045, #10475); and the scan cost ~700 ms per pane, so
panes multiplied it (#15036). The native snapshot answers the same question in
15.9 ms against 706 ms for CIM — p50, measured on Windows 11 at 1050 processes.
See
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
Asking for fewer fields is cheaper, and the module now asks for the smallest set
that still answers every caller. There is **no** per-flag-set cache split: one
TTL-cached snapshot serves everyone, deliberately, because a split would restore
the per-pane fan-out the cache exists to remove — a 32-wide teardown has to
collapse into one scan. So the cheap identity-only read is not something any
caller can select; every read pays for `CommandLine`. An earlier revision of this
file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492
processes. That design is not in the tree and those numbers describe no code
path here; the figures that do apply are the module's own, in
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
**How an EDR reads it:** a cross-process handle plus a remote memory read against
every process on the box, repeating on a cadence, is the read half of the
telemetry that credential dumping and process injection produce. MDE surfaced it
as "suspicious memory activity".
**That signal is still present.** An earlier revision of this file claimed the
command line "now comes from the kernel" through `NtQueryInformationProcess`'s
`ProcessCommandLineInformation` class, needing only
`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from
the compiled addon. None of that is true of the code we ship.
`process_commandline.cc` calls `NtQueryInformationProcess` with
`ProcessBasicInformation` only — to locate the PEB — and then issues three
`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the
`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an
import table, and no such assertion would pass.
What this change did remove is the `Memory` flag's second handle and its
`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves.
What remains to declare to administrators is unchanged in kind: one
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every
process on the box, at the shared snapshot's cadence. Moving to
`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION`
only) would genuinely retire the remote read, but it is an addon patch nobody has
written; treat it as unclaimed work, not as shipped.
### Encoded, policy-bypassing PowerShell
Three sites are named in the incident analysis:
- `src/relay/windows-port-scan.ts` ran
`-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand` over a
`Get-NetTCPConnection -State Listen` script to find dev-server ports.
Enumerating listening ports is **MITRE T1049**, network service discovery, and
doing it through an encoded policy-bypassed shell is the aggravating factor
rather than the finding itself. The ordinary scan now starts no PowerShell at
all — `netstat.exe -ano`, with the owning process name projected off the shared
native table — and that payload survives only as the last-resort fallback, as
`-Command` with no policy override.
- `src/main/daemon/shell-ready.ts` uses `-EncodedCommand` for the OSC 133
bootstrap.
- `src/main/agent-hooks/windows-powershell-hook-launcher.ts` wraps managed hooks.
**No site spells the pair any more.** `src/main/ssh/ssh-remote-powershell.ts`,
`src/shared/setup-agent-sequencing.ts`,
`src/shared/windows-cmd-runner-delayed-launch.ts` and
`src/shared/windows-interactive-login-spawn.ts` each dropped
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script *files*,
never `-EncodedCommand`. Where the bypass was load-bearing it moved in-payload as
a process-scope `Set-ExecutionPolicy` (`setup-agent-sequencing.ts`), which is the
pattern to copy rather than restoring the switch — the switch loses to a GPO
scope anyway, so it never covered the locked-down case.
What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
(`src/main/daemon/shell-ready.ts`, `src/main/providers/local-pty-shell-ready.ts`,
`src/main/providers/windows-shell-args.ts`), the hook wrappers
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
`src/main/agent-hooks/runtime-home-hook-command.ts`,
`src/main/agent-hooks/installer-utils.ts`, `src/main/claude/hook-settings.ts`),
`src/main/runtime/windows-default-route-interfaces.ts`,
`src/main/runtime/orchestration/setup-completion-signal.ts`,
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
`src/main/runtime/windows-mobile-firewall.ts` encodes a script and launches it
_elevated_ through `Start-Process -Verb RunAs`, which is a stronger shape than
any of those; only that hop is encoded, because `-ArgumentList` re-splits an
unquoted parameter string on whitespace.
One site still spells `-ExecutionPolicy Bypass` with **no** encoding, the weaker
signal: `src/main/cli/wsl-cli-scripts.ts` (`-File`, and it is a real script
file, so the switch is not a no-op there). `src/main/system-fonts.ts` dropped it
for plain `-Command`; `src/shared/secure-path-windows-acl.ts` no longer runs
PowerShell at all, having moved to `icacls.exe`; and computer use now asks for
`-ExecutionPolicy RemoteSigned` in
`src/main/computer/windows-powershell-execution-policy.ts`, falling back to
`Bypass` only after a policy-blocked start.
Regenerate with `rg -- '-EncodedCommand|-ExecutionPolicy' src/` rather than
trusting the lists above, and note that a raw grep under-reports: the hook sites
reach `-EncodedCommand` through `wrapWindowsPowerShellEncodedCommand` and never
spell the flag themselves.
Encoding is not gratuitous: it shields paths and switches from `cmd.exe` and MSYS
rewriting (#6078, #14815), which is a real class of corruption. But
`-EncodedCommand` is a first-class Defender alert title ("Suspicious PowerShell
command line"), and base64 raises the score rather than lowering it, because it
denies the analyser the payload it would otherwise clear.
The hook launcher is prior art worth knowing about. #16003 measured, on a
reporting Kaspersky host, that `-WindowStyle Hidden` paired with
`-EncodedCommand` was denied at `CreateProcess` with exit 126 regardless of
payload — `exit 0` was denied too. The fix was to stop *spelling* the flags:
`WINDOWS_POWERSHELL_HOOK_SWITCHES` is now just `-NoProfile`, and separately, in
#16576, the execution policy bypass moved in-payload as a process-scope
`Set-ExecutionPolicy` — a real command-line signal reduction, though #16003's
measured denial keyed on `-WindowStyle Hidden` + `-EncodedCommand`, not on the
bypass. It is also honest that the underlying behaviour did not change.
Copy the pattern, but copy its caveat too. `windows-powershell-hook-launcher.ts`
records that dropping `-WindowStyle Hidden` was a real tradeoff whose suppression
"was never measured" and "remains unverified on a real box". Reducing spelled
flags is the right instinct; treat any specific claim about what a removed flag
was doing as unproven until someone measures it.
### `cmd.exe /c` carrying caret-escaped free text
`buildWindowsCmdShimCommandLine` in
`src/shared/child-process/windows-command-line.ts` builds `/d /v:off /s /c "…"`
for the `.cmd` and `.bat` targets Windows can only start through `cmd.exe`
(`codex.cmd` being the one that matters). Because cmd expands `%VAR%` even inside
a quoted token, each `%` is broken with `"^%"`.
The escaping is not decorative. Measured on Windows 11 against a real `.cmd`
shim, `["a b", 'c"d', "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d',
"e^%F^%g", "h"]` — the `&` truncated the argument *and* ran the remainder as a
command.
**How an EDR reads it:** caret escaping is the canonical obfuscation marker in
`cmd.exe` command lines, and the free text being escaped here is an agent prompt,
so the line is long, high-entropy, and attacker-shaped. It is the exact input an
obfuscated-command-line detector is tuned on.
### The spawn tree itself
`Orca.exe` → `orca-terminal-daemon.exe` → a shell → an agent CLI is what a
terminal multiplexer for coding agents *is*. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
`src/relay/pty-shell-utils.ts` (reading the OpenSSH `DefaultShell`).
Nothing here is avoidable in principle. What is controllable is depth and
breadth: every interpreter hop between Orca and the thing the user asked for adds
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
*shorten the interpreter chain* rather than to hide a window.
### Computer use: screen capture, synthetic input, runtime-compiled MSIL
`native/computer-use-windows/runtime.ps1` is a large PowerShell script.
`src/main/computer/desktop-script-provider-bridge.ts` launches it as
`powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned
-File runtime.ps1 <operation.json>`, retrying once at `Bypass` only if the start
comes back policy-blocked — **once per operation**, with
`desktop-script-provider-client.ts` writing a fresh `operation.json` into a new
temp directory each time. On every launch the script runs `Add-Type
-TypeDefinition` over inline C# that P/Invokes `SendInput` and the window APIs,
then captures the screen through `Graphics.CopyFromScreen`.
That is four separate high-signal behaviours stacked in one process:
| Behaviour | How it is scored |
| ----------------------------------------------- | ---------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
The bottom two rows are the two the incident text named directly, and they are
also the two a persistent runtime host would remove: a long-lived helper compiles
its P/Invoke stubs once and answers operations over a channel, so neither the
MSIL recompilation nor the interpreter burst repeats. A change doing that is in
flight and unmerged at the time of writing; check the code rather than this
paragraph for what the shipped build does. Screen capture and `SendInput` are
inherent to the feature and no refactor removes them.
## Signing is not the gate
The most useful calibration in the whole incident set came from the reporter's
own machine: **Antigravity IDE's main executable is `NotSigned` and was not
flagged, while Orca's is signed and was flagged six times.** Their conclusion:
_"signing is not the gate here — behaviour is."_
The mechanism is that Defender reputation is signer **plus prevalence**, and
prevalence is keyed on **file hash**. A widely installed unsigned binary clears
on install count alone. Orca's signature is a free OV certificate from SignPath
Foundation (`config/electron-builder.config.cjs` sets
`win.signtoolOptions.publisherName`; `config/scripts/verify-windows-inner-signature.mjs`
pins `CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US`),
shared across many OSS projects, with no independent SmartScreen or MAPS
reputation of its own. Every release ships new hashes, so whatever prevalence a
build accumulates resets on the next update. Dev channels ship unsigned by
design, because SignPath's approval waits cannot fit a dev cadence
(`config/scripts/verify-dev-channel-packaging.mjs`).
Signing the uninstaller is worth doing — an unsigned `old-uninstaller.exe`
running under a signed installer is a gratuitous contribution to the update
cluster — but do not expect it to change the behavioural verdict. The three
non-update clusters contain no unsigned binary at all.
## What we do not know
Two limits the incident analysis recorded, kept here rather than smoothed over:
- **No data on Hermes.** Nothing in this document describes how Hermes behaves
under the same tenant policy — though `src/shared/hermes-startup-query.ts` does
spell `-EncodedCommand`, so the gap is telemetry, not surface.
- **Antigravity not being flagged is absence of evidence, not proof.** It is one
reporter's recollection from one machine, not a measurement. It is strong
enough to falsify "the problem is that we are not signed well enough"; it is
not strong enough to support a positive claim about how Defender scores that
product.
Add to those: this is one tenant with one policy configuration. Whether the same
build scores the same way elsewhere is unmeasured.
## Guidance for engineers
Fixes for several of the shapes above are in flight in separate changes; nothing
in this section should be read as a statement that a given site has already
changed. Check the code before relying on it.
The checklist. On Windows, do not reach for:
| Don't | Instead |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | An installer or updater that does not need the rename. Where the rename is load-bearing, document it as such |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:
- **Shorten the interpreter chain.** Each hop between Orca and the user's actual
target is a scored edge and a place for AV to deny a `CreateProcess`. This is
the shipped doctrine of #15520 and #15595.
- **Do not spell a flag you can avoid spelling.** #16003 measured a denial that
was independent of the payload and keyed purely on the switch combination on
the command line. What is on the line is itself the detection surface.
## Guidance for administrators deploying Orca
### Path exclusions alone will not silence these
This is the single most important operational point, and it is the one most
commonly got wrong. The six incidents are **MDE EDR behavioural alerts**.
Defender Antivirus path exclusions suppress *scan* detections; they do not
suppress EDR behavioural alerts the same way. Adding
`%LOCALAPPDATA%\Programs\orca\` to the AV exclusion list and expecting the
incidents to stop will not work.
### What actually stops incidents being created
An **MDE alert suppression rule** scoped to the process tree. Build it in
Microsoft 365 Defender (Settings → Endpoints → Alert suppression), conditioned
on:
- **Alert titles** — `A suspicious file was observed` and
`Suspicious PowerShell command line`, plus any further titles your tenant
actually produced. Take the titles from your own incidents rather than from
this list.
- **File paths** — `Orca.exe` and `orca-terminal-daemon.exe` under
`%LOCALAPPDATA%\Programs\orca\` and `%LOCALAPPDATA%\Orca\daemon-host\`.
Scope it as narrowly as your tenant will tolerate, and review it when Orca
updates: the `daemon-host` path carries a `<version>` segment, so a rule pinned
to one version will silently stop matching. Two traps in that path in particular.
Materialization stages into a `<version>.staging-<hex>` sibling before renaming
it into place, so an exact-version rule misses the tree **mid-update** — which is
precisely when the update-cluster incidents fire. And the root falls back to the
Electron `userData` path when `LOCALAPPDATA` is unset, so
`%LOCALAPPDATA%\Orca\daemon-host\` is the normal location rather than a
guaranteed one. Prefer a prefix match on `…\Orca\daemon-host\` over a rule
pinned to one full path.
Add AV path exclusions for those two directories as well — they cut scan cost on
a tree that is rewritten on every update — but understand the division of
labour. The exclusions reduce scanning; **the suppression rule is what stops
incidents being created.**
### Check your ASR rules
Check whether the tenant has the Attack Surface Reduction rule **"Block
executable files from running unless they meet a prevalence, age, or trusted list
criterion"** enabled. If it is, that alone explains a freshly signed Orca build
being hit immediately after every update: each release ships new hashes, so every
build starts at zero prevalence and zero age no matter how it is signed. Either
allowlist the Orca install paths for that rule or expect a hit on each update.
### Expect the alerts to recur after each update
Prevalence is keyed on file hash. An update replaces the hashes, the reputation
starts over, and a suppression rule is the only thing carrying across.
## Computer use: decide before you deploy
Read this section before enabling computer use on a monitored endpoint, not
after.
> **On a monitored endpoint, an alert reading "Screenshots were taken
> unexpectedly on this device" is not the kind of finding a SOC dismisses on
> sight.**
Incident E is the shape to expect: 5 alerts, 37 evidence items, a multi-stage
incident mapped to ATT&CK **Execution + Collection**, and a description naming
screen capture found in a script launched by `powershell.exe`. Incident F adds
runtime-compiled MSIL to the same tree.
Every part of that is an accurate description of what the feature does. Orca's
computer use takes screenshots, synthesises keyboard and mouse input into other
applications, and compiles the P/Invoke stubs it needs at runtime. An
organisation that monitors for Collection-tactic activity — and any organisation
running MDE with default incident creation does — will see it, and will see it
as Collection.
So decide deliberately, in advance:
- **Allowlist it**, with a suppression rule covering the computer-use tree
(`powershell.exe` with `-File …\runtime.ps1`) as well as the base Orca paths,
and tell your SOC what it is before the first incident rather than during it.
- **Or leave it disabled** on monitored endpoints.
What does not work is deploying it un-triaged and handling the incidents
reactively. By the time a Collection-tactic incident is open, an analyst is
already reading a description of screenshots being taken without the user's
knowledge, and the burden of proof has moved to you.
+13 -2
View File
@@ -41,6 +41,15 @@ Measured on Windows 11 with 1050 processes (p50 / p95):
| + memory + command line | 30.6 ms | 33.7 ms |
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
Those are the module's published figures. The flag set this module actually
requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second
`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus
`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing
read. Dropping it halves the handles a snapshot opens. The remaining set sits
between the two rows above and has not been measured separately; on a real
Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a
snapshot cadence is the check.
Those CIM numbers are from a 1050-process host. The scan scales with process
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
**4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits
@@ -236,11 +245,13 @@ stronger than reconstructing ownership from process-table fields.
## What the snapshot does not provide
Committed private bytes have no equivalent either, and the one memory value the
snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores
snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
second reason `windows-process-resource-collector.ts` still runs its own
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
counters in the same pass. Migrating it to the native table would cost both.
counters in the same pass. Migrating it to the native table would cost both, and
it is why this module no longer sets the `Memory` flag at all: the field had no
reader, and asking for it opened a handle per process on every snapshot.
Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples
inside one call with a blocking `Sleep(1000)` in the middle, which would hold a
@@ -35,11 +35,11 @@ silent, and indistinguishable from the real thing.
Three instances so far:
| Where | What the user saw | Status |
| --- | --- | --- |
| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
| Where | What the user saw | Status |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Preflight CLI probes | Caching the result would have pinned "git not installed" until relaunch | Bounded entry ([#17350](https://github.com/stablyai/orca/pull/17350)) |
| `glab auth status` fallback into WSL | Idle VM woken repeatedly for users who never touch GitLab | Open ([#8941](https://github.com/stablyai/orca/issues/8941)) |
| `listRunningWslDistrosAsync` | Fails closed to `[]` with no last-known-good, polled every 2s — a persistently broken `wsl.exe` makes every WSL session vanish app-wide | Open (PR #17072 review) |
## What to do instead
+1 -1
View File
@@ -14,7 +14,7 @@ import { Callout } from '@/components/docs/prose'
The Orca CLI is the `orca` command-line interface for scripting a running Orca editor from any shell. Use it to create and inspect worktrees, drive agent terminals, open files and diffs, automate the built-in browser, run scheduled automations, share HTML/Markdown artifacts, and control Orca-native tools from scripts or AI agents.
It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings).
It ships with the desktop app; register it under [Settings → General → Orca CLI](/docs/settings). On Linux the command is `orca-ide`, because GNOME Orca's screen reader already owns `/usr/bin/orca` — see [Install → Linux](/docs/install#linux).
Agents can install the matching Orca CLI skill with:
+17 -1
View File
@@ -9,13 +9,29 @@ The `orca` CLI talks to a running Orca runtime. Use it when a shell script or ag
## Verify the runtime
Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca:
Register the CLI under [Settings → General → Orca CLI](/docs/settings), then check that it can reach Orca.
<Callout title="On Linux the command is orca-ide">
GNOME Orca — the screen reader that ships with most GNOME desktops — already owns `/usr/bin/orca`,
so Orca's Linux CLI installs as `orca-ide`. Do not check for it with `command -v orca`: that
succeeds on a GNOME desktop and resolves to the screen reader, not to Orca. This page writes
`orca` throughout — read it as `orca-ide` on Linux. See [Install → Linux](/docs/install#linux).
</Callout>
On macOS and Windows:
```bash
command -v orca
orca status --json
```
On Linux:
```bash
command -v orca-ide
orca-ide status --json
```
If Orca is not already running:
```bash
+53 -3
View File
@@ -31,8 +31,11 @@ import { Callout } from '@/components/docs/prose'
</li>
<li>
**Linux:**
[AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases)
AppImage
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases) ·
[.rpm](https://github.com/stablyai/orca/releases) — see [Linux](#linux) for which to pick
</li>
<li>Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).</li>
</ul>
@@ -59,6 +62,8 @@ On first launch Orca will:
Orca auto-updates by default, tracking the **stable** channel. Stable releases are vetted; **RC (release candidate)** builds ship new features first, often daily.
On Linux, whether Orca can apply an update itself depends on which package you installed. See [Linux](#linux) before you pick one.
There is no permanent in-app opt-in for the RC channel. Modifier clicks on **Check for Updates** ([Settings → General → Updates](/docs/settings), or the app / Help menu):
| Modifier | Effect |
@@ -87,4 +92,49 @@ The default shell can be set to PowerShell or CMD under [Settings → Terminal](
### Linux
AppImage and `.deb` builds are available. See the Releases page for details.
Each published release ships three Linux packages — an **AppImage**, a **`.deb`**, and an **`.rpm`** — for both x64 and arm64. They contain the same app. What differs is how updates reach you, so pick on that.
| Package | Pick it when | Updates |
| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------- |
| **AppImage** | You want Orca to update itself, like on macOS and Windows | Orca downloads and applies the update in place |
| **`.deb`** | You manage software with `apt` on Debian or Ubuntu | Orca tells you a version is out and hands you the install command |
| **`.rpm`** | You manage software with `dnf`, `yum`, or `zypper` | Same as `.deb` |
The AppImage has a stable download link per architecture — [`orca-linux.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) for x64 and [`orca-linux-arm64.AppImage`](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) for arm64 — and needs `chmod +x` before its first run, because GitHub release assets carry no permission bits. The `.deb` and `.rpm` filenames carry the version and architecture, and the two formats spell architecture differently (`orca-ide_<version>_amd64.deb` or `_arm64.deb`; `orca-ide-<version>.x86_64.rpm` or `.aarch64.rpm`), so take those from the [Releases page](https://github.com/stablyai/orca/releases) rather than a fixed URL.
#### How updating works
**The AppImage self-updates.** Choose it if you want automatic updates. Orca checks for a new release, you click **Update**, and it replaces the AppImage in place — the same flow as macOS and Windows.
**The `.deb` and `.rpm` do not self-update.** Orca still notices the new version and downloads the package, then gives you a **Copy Install Command** button. Copy it rather than retyping it: Orca resolves every program to an absolute path in a trusted system directory and single-quotes the package path, so what you paste looks like this:
```
/usr/bin/sudo /usr/bin/apt install -- '/home/you/.cache/orca-updater/pending/orca-ide_1.4.194_amd64.deb'
```
Which package manager appears depends on what your system actually has: `apt`, else `dpkg -i`, for a `.deb`; `zypper`, `dnf`, `yum`, then `rpm -Uvh` for an `.rpm`. The download directory follows `XDG_CACHE_HOME` when that is set and falls back to `~/.cache` when it is not.
**Quit Orca before you run the command**, then reopen it once the install finishes. You are replacing the files of a running application, and the package manager cannot swap them safely underneath a live process. Orca deliberately never escalates privileges to do this for you: installing a system package needs root, `orca serve` runs as an unprivileged user, and a headless machine has no authentication agent to prompt. VS Code and Signal make the same call on `.deb`.
**A distro-managed build is left alone.** If you are running a repackaged Orca — an AUR build, a Nix derivation — Orca sees that no package manager it can drive owns this install and stops offering a download it could never apply. It still reports that a new version exists, so you can update the way you normally would.
<Callout title="Planned: a signed apt/yum repository">
[#18086](https://github.com/stablyai/orca/issues/18086) tracks publishing a signed repository so
your OS package manager owns Orca updates the way it owns everything else. It does not exist yet —
today, `.deb` and `.rpm` updates are the manual step described above.
</Callout>
#### The CLI command is `orca-ide`
On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `orca`. GNOME Orca — the screen reader that ships by default on Ubuntu and other GNOME desktops — already owns `/usr/bin/orca`, and Orca will not shadow it. The `.deb` and `.rpm` packages are named `orca-ide` for the same reason.
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
```
ln -s "$(command -v orca-ide)" ~/.local/bin/orca
```
@@ -126,6 +126,14 @@ Use `orca serve` when the host should run without the desktop window—for examp
Install Orca and its bundled CLI on the server, then run:
<Callout title="On Linux, start it with orca-ide serve">
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
while it is starting, so that shim can never be the command that starts the server. Read
`orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
[Install → Linux](/docs/install#linux).
</Callout>
```bash
orca serve --pairing-address <server-tailscale-ip-or-hostname>
```
@@ -2,11 +2,14 @@
title: Annotate AI Diff
---
import { ImagePlaceholder } from '@/components/docs/prose';
import { ImagePlaceholder } from '@/components/docs/prose'
Annotate AI Diff is Orca's inline review loop for agent-generated code. You leave comments on any line of any AI-generated hunk, then send them back to the agent as a single batch for revision — no copying line numbers, no context-switching.
<ImagePlaceholder src="/docs/annotate-ai-diff.gif" caption="Annotate a generated diff and send one batch of line-level notes back to the agent" />
<ImagePlaceholder
src="/docs/annotate-ai-diff.gif"
caption="Annotate a generated diff and send one batch of line-level notes back to the agent"
/>
## Leave a comment
+2 -2
View File
@@ -52,10 +52,10 @@ Keep Orca running on a machine you control—an old laptop, Mac mini, home serve
**Easiest setup:** install Orca and Tailscale on both computers. On the server, open **Settings → Remote Orca Servers → Advertise this app as a server → New Link**, choose its Tailscale address, and generate an access link. On the client, choose **Add Server** and paste that link.
For a headless Linux server or service-managed VM, use `orca serve` as the alternative:
For a headless Linux server or service-managed VM, use `orca serve` as the alternative. On Linux the CLI is named `orca-ide`, so the first launch is:
```bash
orca serve --pairing-address <reachable-tailscale-ip-or-hostname>
orca-ide serve --pairing-address <reachable-tailscale-ip-or-hostname>
```
Full detail: [Remote Orca Servers](/docs/remote-servers).
+2 -1
View File
@@ -75,6 +75,7 @@
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check",
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
"test:linux-cli-contract": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
"test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs",
@@ -94,7 +95,7 @@
"build:icons": "bash resources/icon-source/generate.sh",
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && node config/scripts/build-linux-local.mjs",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
+3 -3
View File
@@ -115,7 +115,7 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: 572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e
node-pty@1.1.0: 40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e
importers:
@@ -156,7 +156,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e)
version: 1.1.0(patch_hash=40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12194,7 +12194,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=572a46f539dd9da26e259702da974e1e693329e299625c97eb7c28e4e642500e):
node-pty@1.1.0(patch_hash=40b6b6b814c89a8a29c995495ea86cff2cf6766f42124b5702aedf4ce0565c0e):
dependencies:
node-addon-api: 7.1.1
+1
View File
@@ -38,4 +38,5 @@ export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
# CLI commands run in Electron's Node mode and must never initialize Chromium.
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"
@@ -4,6 +4,12 @@
# /usr/bin/orca-ide a user or other package may own.
set -e
# RPM passes an instance count; dpkg passes the package lifecycle action.
case "${1-}" in
0 | remove | purge) ;;
*) exit 0 ;;
esac
link="/usr/bin/orca-ide"
if [ -L "$link" ]; then
+20
View File
@@ -93,6 +93,16 @@ describe('parseArgs', () => {
expect(parsed.flags.get('repo')).toBe('id:abc')
})
it('preserves a project selector before the project command', () => {
const parsed = parseArgs(
['--project', 'github:stablyai/orca', 'project', 'setups'],
[['project', 'setups']]
)
expect(parsed.commandPath).toEqual(['project', 'setups'])
expect(parsed.flags.get('project')).toBe('github:stablyai/orca')
})
it('preserves a selector value that is also a registered command', () => {
const parsed = parseArgs(
['--environment', 'status', 'worktree', 'list'],
@@ -113,6 +123,16 @@ describe('parseArgs', () => {
expect(parsed.flags.get('environment')).toBe('worktree')
})
it.each([
['--project', 'project', 'project', 'setups'],
['--project=project', 'project', 'setups']
])('preserves a command-named project selector in %j', (...args) => {
const parsed = parseArgs(args, [['project', 'setups']])
expect(parsed.commandPath).toEqual(['project', 'setups'])
expect(parsed.flags.get('project')).toBe('project')
})
it('parses emulator reinstall as a boolean flag', () => {
const parsed = parseArgs(['emulator', 'install', 'app.apk', '--reinstall', '--device', 'emu'])
+11 -64
View File
@@ -1,6 +1,12 @@
import { RuntimeClientError } from './runtime/types'
import { unknownCommandData, unknownFlagData } from './command-suggestion'
import { specPaths, type CommandSpec } from './command-spec'
import {
CLI_BOOLEAN_FLAGS,
CLI_GLOBAL_FLAGS,
CLI_GLOBAL_VALUE_FLAGS,
findCliCommandIndex
} from '../shared/cli-argument-boundary'
export { specPaths }
export type { CommandSpec }
@@ -11,51 +17,9 @@ export type ParsedArgs = {
positionalFlagConflicts?: string[]
}
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment'])
export const BOOLEAN_FLAGS = new Set([
'all',
'attachments',
'children',
'comments',
'connect',
'current',
'dry-run',
'enter',
'focus',
'force',
'full',
'help',
'inject',
'include-archived',
'include-visual-layouts',
'interrupt',
'json',
'local',
'messages',
'me',
'mobile',
'mobile-pairing',
'no-pairing',
'screen',
'parent-current',
'provision',
'ready',
'recipe-json',
'relations',
'reinstall',
'restore-window',
'return-preamble',
'run-hooks',
'show-profile',
'staged',
'tab',
'tasks',
'text-stdin',
'unread',
'value-stdin',
'wait'
])
export const GLOBAL_FLAGS = CLI_GLOBAL_FLAGS
const GLOBAL_VALUE_FLAGS = new Set(CLI_GLOBAL_VALUE_FLAGS)
export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS
export const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
@@ -69,25 +33,10 @@ function setFlagValue(flags: Map<string, string | boolean>, name: string, value:
flags.set(name, value)
}
function commandPathStartsAt(argv: string[], tokenIndex: number, path: string[]): boolean {
let cursor = tokenIndex
for (const part of path) {
while (argv[cursor]?.startsWith('--')) {
const assignment = argv[cursor].slice(2)
const flag = assignment.split('=', 1)[0]
cursor += assignment.includes('=') || BOOLEAN_FLAGS.has(flag) ? 1 : 2
}
if (argv[cursor] !== part) {
return false
}
cursor += 1
}
return true
}
export function parseArgs(argv: string[], commandPaths?: readonly string[][]): ParsedArgs {
const commandPath: string[] = []
const flags = new Map<string, string | boolean>()
const commandIndex = findCliCommandIndex(argv, commandPaths ?? [])
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
@@ -112,9 +61,7 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P
continue
}
// Why: a pre-command flag must not consume a registry-resolvable command path.
const startsCommandAt = (tokenIndex: number): boolean =>
commandPaths?.some((path) => commandPathStartsAt(argv, tokenIndex, path)) ?? false
if (commandPath.length === 0 && startsCommandAt(i + 1) && !startsCommandAt(i + 2)) {
if (commandPath.length === 0 && i + 1 === commandIndex) {
flags.set(flag, true)
continue
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { CLI_COMMAND_NAMES } from '../main/startup/cli-command-names'
import { COMMAND_SPECS } from './specs'
const specCommandNames = [...new Set(COMMAND_SPECS.map((spec) => spec.path[0]))].sort()
describe('CLI command-name parity between COMMAND_SPECS and the launch redirect', () => {
it('has commands to compare', () => {
expect(specCommandNames.length).toBeGreaterThan(0)
})
it('redirects every top-level CLI command', () => {
const redirected = new Set<string>(CLI_COMMAND_NAMES)
expect(specCommandNames.filter((name) => !redirected.has(name))).toEqual([])
})
it('lists no command that COMMAND_SPECS does not define', () => {
const specNames = new Set(specCommandNames)
expect([...CLI_COMMAND_NAMES].filter((name) => !specNames.has(name))).toEqual([])
})
it('stays sorted and free of duplicates so additions are easy to review', () => {
expect([...CLI_COMMAND_NAMES]).toEqual([...new Set(CLI_COMMAND_NAMES)].sort())
})
})
+36
View File
@@ -0,0 +1,36 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readOrcaCliVersion } from './cli-version'
const temporaryDirectories: string[] = []
afterEach(() =>
Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true })))
)
describe('CLI version', () => {
it('reads the package boundary beside the compiled CLI', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-'))
const runtimeDir = join(root, 'cli')
temporaryDirectories.push(root)
await mkdir(runtimeDir)
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.4.178-rc.2' }))
expect(readOrcaCliVersion(runtimeDir)).toBe('1.4.178-rc.2')
})
it('rejects missing, malformed, and non-string versions', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-cli-version-invalid-'))
const runtimeDir = join(root, 'cli')
temporaryDirectories.push(root)
await mkdir(runtimeDir)
expect(readOrcaCliVersion(runtimeDir)).toBeNull()
await writeFile(join(root, 'package.json'), '{')
expect(readOrcaCliVersion(runtimeDir)).toBeNull()
await writeFile(join(root, 'package.json'), JSON.stringify({ version: 178 }))
expect(readOrcaCliVersion(runtimeDir)).toBeNull()
})
})
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
// Node-mode CLI code cannot read the package metadata inside app.asar.
export function readOrcaCliVersion(runtimeDir = __dirname): string | null {
try {
const parsed = JSON.parse(readFileSync(join(runtimeDir, '..', 'package.json'), 'utf8')) as {
version?: unknown
}
return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null
} catch {
return null
}
}
+3 -24
View File
@@ -1,4 +1,7 @@
import { specPaths, type CommandSpec } from './command-spec'
import { levenshtein } from '../shared/edit-distance'
export { levenshtein } from '../shared/edit-distance'
// Why: rank the live registry so typo recovery cannot drift from accepted paths.
@@ -46,30 +49,6 @@ export type CommandErrorData = {
nextSteps: string[]
}
export function levenshtein(a: string, b: string): number {
const m = a.length
const n = b.length
if (m === 0) {
return n
}
if (n === 0) {
return m
}
let prev = Array.from({ length: n + 1 }, (_, index) => index)
let curr = Array.from({ length: n + 1 }, () => 0)
for (let i = 1; i <= m; i += 1) {
curr[0] = i
for (let j = 1; j <= n; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
}
const swap = prev
prev = curr
curr = swap
}
return prev[n]
}
// Why: one bounded near-match ranking keeps command and flag recovery consistent.
function rankByDistance(scored: { label: string; distance: number }[]): string[] {
return scored
+19 -32
View File
@@ -3,6 +3,7 @@ import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
import { stripElectronRunAsNode } from '../runtime/launch'
import { getServeOptionValidationError } from '../../shared/serve-option-validation'
function envRecord(): Record<string, string> {
// Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI
@@ -92,43 +93,29 @@ export const CORE_HANDLERS: Record<string, CommandHandler> = {
printResult(result, json, formatCliStatus)
},
serve: async ({ flags, json }) => {
if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --mobile-pairing or --no-pairing, not both.'
)
}
if (flags.get('recipe-json') === true && flags.get('no-pairing') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Recipe JSON output requires runtime pairing; remove --no-pairing.'
)
}
if (flags.get('recipe-json') === true && flags.get('mobile-pairing') === true) {
throw new RuntimeClientError(
'invalid_argument',
'Recipe JSON output requires runtime pairing; remove --mobile-pairing.'
)
}
const projectRoot =
typeof flags.get('project-root') === 'string' ? (flags.get('project-root') as string) : null
if (flags.get('recipe-json') === true && !projectRoot) {
throw new RuntimeClientError(
'invalid_argument',
'Recipe JSON output requires --project-root.'
)
const projectRootValue = flags.get('project-root')
const projectRoot = typeof projectRootValue === 'string' ? projectRootValue : null
const noPairing = flags.get('no-pairing') === true
const mobilePairing = flags.get('mobile-pairing') === true
const recipeJson = flags.get('recipe-json') === true
const validationError = getServeOptionValidationError({
noPairing,
mobilePairing,
recipeJson,
projectRoot
})
if (validationError) {
throw new RuntimeClientError('invalid_argument', validationError)
}
const port = getOptionalServePort(flags)
const pairingAddressValue = flags.get('pairing-address')
const exitCode = await serveOrcaApp({
json,
port,
pairingAddress:
typeof flags.get('pairing-address') === 'string'
? (flags.get('pairing-address') as string)
: null,
noPairing: flags.get('no-pairing') === true,
mobilePairing: flags.get('mobile-pairing') === true,
recipeJson: flags.get('recipe-json') === true,
pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null,
noPairing,
mobilePairing,
recipeJson,
projectRoot
})
process.exitCode = exitCode
+12
View File
@@ -8,6 +8,7 @@ import {
specPaths,
validateCommandAndFlags
} from './args'
import { readOrcaCliVersion } from './cli-version'
import { dispatch } from './dispatch'
import {
assertEnvironmentSelectorResolvable,
@@ -59,6 +60,17 @@ export async function main(
argv = process.argv.slice(2),
cwd = resolveInvocationCwd()
): Promise<void> {
// Why: version audits use the bundled launcher; Electron intercepts direct binary version flags.
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
const version = readOrcaCliVersion()
if (!version) {
process.stderr.write('Could not determine the Orca version for this build.\n')
process.exitCode = 1
return
}
process.stdout.write(`${version}\n`)
return
}
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
+114 -30
View File
@@ -13,12 +13,14 @@ import {
SERVE_REPLACEMENT_READY_TIMEOUT_MS
} from './serve-update-supervisor'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
const { spawnMock, spawnSyncMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
spawnSyncMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
spawn: spawnMock,
spawnSync: spawnSyncMock
}))
import { launchOrcaApp, serveOrcaApp } from './launch'
@@ -86,6 +88,7 @@ describe('serveOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
spawnSyncMock.mockReset()
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
})
@@ -93,7 +96,6 @@ describe('serveOrcaApp', () => {
vi.restoreAllMocks()
delete process.env.ORCA_APP_EXECUTABLE
delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT
delete process.env.ORCA_APPIMAGE_NO_SANDBOX
delete process.env.ORCA_USER_DATA_PATH
return Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
@@ -391,32 +393,6 @@ describe('serveOrcaApp', () => {
)
})
it('preserves an AppImage no-sandbox launch for the server child', async () => {
process.env.ORCA_APPIMAGE_NO_SANDBOX = '1'
const child = {
kill: vi.fn(),
once: vi.fn(
(event: string, handler: (code: number | null, signal: string | null) => void) => {
if (event === 'exit') {
queueMicrotask(() => handler(0, null))
}
return child
}
)
}
spawnMock.mockReturnValue(child)
await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/Applications/Orca.app/Contents/MacOS/Orca',
['--no-sandbox', '--serve', '--serve-json'],
expect.any(Object)
)
const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv }
expect(spawnOptions.env).not.toHaveProperty('ORCA_APPIMAGE_NO_SANDBOX')
})
it('passes the app root before serve flags for dev Electron executables', async () => {
process.env.ORCA_APP_EXECUTABLE = '/repo/node_modules/.bin/electron'
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
@@ -444,6 +420,66 @@ describe('serveOrcaApp', () => {
)
})
it.each([
{ probe: 'exits nonzero', result: { status: 1 }, expectedPrefix: ['--no-sandbox'] },
{ probe: 'succeeds', result: { status: 0 }, expectedPrefix: [] },
{
probe: 'times out',
result: {
status: null,
error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })
},
expectedPrefix: ['--no-sandbox']
},
{
probe: 'cannot start',
result: { status: null, error: Object.assign(new Error('missing'), { code: 'ENOENT' }) },
expectedPrefix: ['--no-sandbox']
}
])(
'uses the extracted AppImage sandbox fallback when the userns probe $probe',
async ({ result: userNamespaceResult, expectedPrefix }) => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
const getuidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid')
const root = await mkdtemp(join(tmpdir(), 'orca-extracted-appimage-'))
temporaryDirectories.push(root)
const executable = join(root, 'orca-ide')
await writeFile(join(root, 'AppRun'), '', { mode: 0o755 })
process.env.ORCA_APP_EXECUTABLE = executable
Object.defineProperty(process, 'platform', { value: 'linux' })
Object.defineProperty(process, 'getuid', { configurable: true, value: () => 1000 })
spawnSyncMock.mockReturnValue(userNamespaceResult)
const child = new FakeChildProcess()
spawnMock.mockReturnValue(child)
try {
const result = serveOrcaApp({ json: true })
queueMicrotask(() => child.emit('exit', 0, null))
await expect(result).resolves.toBe(0)
expect(spawnSyncMock).toHaveBeenCalledWith(
'unshare',
['-Ur', 'true'],
expect.objectContaining({ stdio: 'ignore', timeout: 2_000 })
)
expect(spawnMock).toHaveBeenCalledWith(
executable,
[...expectedPrefix, '--serve', '--serve-json'],
// Foreground serve must share POSIX job-control signals with its CLI supervisor.
expect.objectContaining({ detached: false })
)
} finally {
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
if (getuidDescriptor) {
Object.defineProperty(process, 'getuid', getuidDescriptor)
} else {
Reflect.deleteProperty(process, 'getuid')
}
}
}
)
it('prints recipe JSON from a detached server child and exits', async () => {
const child = new FakeChildProcess()
spawnMock.mockReturnValue(child)
@@ -599,6 +635,7 @@ describe('serveOrcaApp', () => {
describe('launchOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
spawnSyncMock.mockReset()
})
afterEach(() => {
@@ -618,4 +655,51 @@ describe('launchOrcaApp', () => {
expect(child.unref).toHaveBeenCalled()
})
it('adds the extracted-AppImage sandbox fallback for open launches', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
const getuidDescriptor = Object.getOwnPropertyDescriptor(process, 'getuid')
const root = await mkdtemp(join(tmpdir(), 'orca-open-extracted-appimage-'))
const executable = join(root, 'orca-ide')
try {
await writeFile(join(root, 'AppRun'), '')
process.env.ORCA_APP_EXECUTABLE = executable
process.env.ELECTRON_RUN_AS_NODE = '1'
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
Object.defineProperty(process, 'getuid', { configurable: true, value: () => 1000 })
spawnSyncMock.mockReturnValue({ status: 1 })
const child = new FakeChildProcess()
spawnMock.mockReturnValue(child)
launchOrcaApp()
expect(spawnSyncMock).toHaveBeenCalledWith(
'unshare',
['-Ur', 'true'],
expect.objectContaining({ stdio: 'ignore', timeout: 2_000 })
)
expect(spawnMock).toHaveBeenCalledWith(
executable,
['--no-sandbox'],
expect.objectContaining({
detached: true,
stdio: 'ignore',
env: expect.not.objectContaining({ ELECTRON_RUN_AS_NODE: '1' })
})
)
expect(child.unref).toHaveBeenCalledOnce()
} finally {
await rm(root, { recursive: true, force: true })
delete process.env.ELECTRON_RUN_AS_NODE
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
if (getuidDescriptor) {
Object.defineProperty(process, 'getuid', getuidDescriptor)
} else {
Reflect.deleteProperty(process, 'getuid')
}
}
})
})
+35 -10
View File
@@ -1,6 +1,8 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { resolve } from 'node:path'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { runProcessSync } from '../../shared/child-process/run-process'
import {
SERVE_UPDATE_HANDOFF_PATH_ENV,
getServeUpdateHandoffPath
@@ -19,6 +21,7 @@ import {
import { RuntimeClientError } from './types'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
@@ -29,7 +32,7 @@ export function launchOrcaApp(): void {
const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
spawnDetached(overrideExecutable, getExecutableAppArgs(), {
spawnDetached(overrideExecutable, getExecutableAppArgs(overrideExecutable), {
...getExecutableSpawnOptions(overrideExecutable),
env: stripElectronRunAsNode(process.env)
})
@@ -50,7 +53,7 @@ export function launchOrcaApp(): void {
}
}
spawnDetached(process.execPath, [], {
spawnDetached(process.execPath, getExecutableAppArgs(process.execPath), {
env: stripElectronRunAsNode(process.env)
})
return
@@ -86,10 +89,7 @@ export function serveOrcaApp(
} = {}
): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
const childArgs = [...getExecutableAppArgs()]
if (process.env.ORCA_APPIMAGE_NO_SANDBOX === '1') {
childArgs.push('--no-sandbox')
}
const childArgs = [...getExecutableAppArgs(executable)]
childArgs.push('--serve')
if (args.json) {
childArgs.push('--serve-json')
@@ -121,7 +121,6 @@ export function serveOrcaApp(
? getServeUpdateHandoffPath(getDefaultUserDataPath())
: null
const childEnv = stripElectronRunAsNode(process.env)
delete childEnv.ORCA_APPIMAGE_NO_SANDBOX
if (handoffPath) {
childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath
}
@@ -256,8 +255,34 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
})
}
function getExecutableAppArgs(): string[] {
return process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
args.push('--no-sandbox')
}
return args
}
function shouldDisableExtractedAppImageSandbox(executable: string): boolean {
if (process.platform !== 'linux' || !existsSync(join(dirname(executable), 'AppRun'))) {
return false
}
// An extracted AppImage has no root-owned setuid sandbox; mirror AppRun's userns fallback.
if (process.getuid?.() === 0) {
return true
}
try {
return (
runProcessSync({
program: 'unshare',
args: ['-Ur', 'true'],
stdio: 'ignore',
timeoutMs: USER_NAMESPACE_PROBE_TIMEOUT_MS
}).code !== 0
)
} catch {
return true
}
}
function getExecutableSpawnOptions(executable: string): Pick<SpawnOptions, 'shell'> {
@@ -134,6 +134,36 @@ describe('superviseForegroundServe signal exits', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('forwards Linux terminal hangup and removes the listener after exit', async () => {
setPlatform('linux')
const listenersBefore = process.listeners('SIGHUP')
const child = new FakeChildProcess()
const supervised = superviseChild(child)
expect(process.listeners('SIGHUP')).toHaveLength(listenersBefore.length + 1)
process.emit('SIGHUP', 'SIGHUP')
expect(child.kill).toHaveBeenCalledWith('SIGHUP')
child.emit('exit', null, 'SIGHUP')
await expect(supervised).resolves.toBe(0)
expect(process.listeners('SIGHUP')).toEqual(listenersBefore)
const killCallsAfterExit = child.kill.mock.calls.length
process.emit('SIGHUP', 'SIGHUP')
expect(child.kill).toHaveBeenCalledTimes(killCallsAfterExit)
})
it('treats a child exit through the caller-forwarded SIGINT as graceful', async () => {
setPlatform('linux')
const child = new FakeChildProcess()
const supervised = superviseChild(child)
process.emit('SIGINT', 'SIGINT')
child.emit('exit', null, 'SIGINT')
await expect(supervised).resolves.toBe(0)
})
it('does not terminate an exited child when update handoff completion fails late', async () => {
vi.useFakeTimers()
const missingParent = await mkdtemp(join(tmpdir(), 'orca-serve-missing-handoff-'))
+14 -3
View File
@@ -86,8 +86,8 @@ export async function superviseForegroundServe(
handoff?.phase !== 'install-requested' ||
(child.pid !== undefined && handoff.servingPid !== child.pid)
) {
if (typeof result.code === 'number') {
return result.code
if (typeof result.code === 'number' || result.signalWasForwarded) {
return result.code ?? 0
}
throw serveSignalExitError(result.signal)
}
@@ -114,8 +114,11 @@ function waitForForegroundChild(
code: number | null
signal: NodeJS.Signals | null
readiness: ServeReadiness
signalWasForwarded: boolean
}> {
return new Promise((resolveWait, reject) => {
const forwardsHangup = process.platform === 'linux'
const forwardedSignals = new Set<NodeJS.Signals>()
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
let readyTimer: ReturnType<typeof setTimeout> | null = null
let readiness: ServeReadiness = expected ? 'pending' : 'not-expected'
@@ -155,6 +158,7 @@ function waitForForegroundChild(
const forwardSignal = (signal: NodeJS.Signals): void => {
// A Windows console delivers Ctrl-C to parent and child; child.kill would terminate the child mid-teardown.
if (process.platform !== 'win32') {
forwardedSignals.add(signal)
child.kill(signal)
}
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), SERVE_CHILD_FORCE_KILL_GRACE_MS)
@@ -188,6 +192,9 @@ function waitForForegroundChild(
const cleanup = (): void => {
process.off('SIGINT', forwardSignal)
process.off('SIGTERM', forwardSignal)
if (forwardsHangup) {
process.off('SIGHUP', forwardSignal)
}
if (typeof child.off === 'function') {
child.off('message', handleMessage)
}
@@ -200,6 +207,9 @@ function waitForForegroundChild(
}
process.on('SIGINT', forwardSignal)
process.on('SIGTERM', forwardSignal)
if (forwardsHangup) {
process.on('SIGHUP', forwardSignal)
}
if (typeof child.on === 'function') {
child.on('message', handleMessage)
}
@@ -213,7 +223,8 @@ function waitForForegroundChild(
const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => {
childSettled = true
cleanup()
void stateWrite.then(() => resolveWait({ code, signal, readiness }))
const signalWasForwarded = signal !== null && forwardedSignals.has(signal)
void stateWrite.then(() => resolveWait({ code, signal, readiness, signalWasForwarded }))
}
child.once('error', (error) => {
childSettled = true
+8 -9
View File
@@ -35,7 +35,7 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite',
expect(normalizeServeModeArgv(argv)).toEqual(expected)
if (takesValue) {
// The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token.
// The equals form is the other shape `orca serve` accepts; normalize it to the internal shape.
expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected)
} else {
// A boolean with an attached value is not a truthy assertion: the CLI reads these as
@@ -53,20 +53,19 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite',
})
it('emits the same --serve-* names the CLI spawns with and the main process reads', () => {
// Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so
// both ends of the contract are only readable statically. Without this leg the rewrite could
// emit a name nothing reads and every behavioural assertion above would still pass.
// Why source text: serveOrcaApp spawns a real process; keeping both names visible here makes
// the rewrite/parser contract fail loudly if either side drifts.
const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8')
const mainSource = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-serve.ts'),
const serveOptionsSource = readFileSync(
join(process.cwd(), 'src/main/startup/serve-options.ts'),
'utf8'
)
const start = mainSource.indexOf('export function getServeOptions(')
const start = serveOptionsSource.indexOf('export function getServeOptions(')
// Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously.
expect(start).toBeGreaterThanOrEqual(0)
const end = mainSource.indexOf('\n}', start)
const end = serveOptionsSource.indexOf('\n}', start)
expect(end).toBeGreaterThan(start)
const getServeOptionsBody = mainSource.slice(start, end)
const getServeOptionsBody = serveOptionsSource.slice(start, end)
for (const flag of translatedFlags) {
expect(launchSource).toContain(`'--serve-${flag}'`)
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import type { EnrichedAgentHookEventPayload } from './server/server-types'
import { buildBody, PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
const CONNECTION = 'conn-1'
const T0 = 1_800_000_000_000
function ingest(
server: AgentHookServer,
payload: Record<string, unknown>,
options: { isReplay?: boolean } = {}
): void {
const event = normalizeHookPayload(
createHookListenerState(),
'claude',
buildBody(payload),
'production'
)
if (!event) {
throw new Error('normalizeHookPayload rejected a known-good Claude fixture')
}
server.ingestRemote({ ...event, ...(options.isReplay ? { isReplay: true } : {}) }, CONNECTION)
}
describe('the observation clock a relay replay must not restamp', () => {
let server: AgentHookServer
let emitted: EnrichedAgentHookEventPayload[]
beforeEach(() => {
_internals.resetCachesForTests()
vi.useFakeTimers()
vi.setSystemTime(T0)
server = new AgentHookServer()
emitted = []
server.setListener((payload) => {
emitted.push(payload)
})
})
afterEach(() => {
server.setListener(null)
vi.useRealTimers()
vi.restoreAllMocks()
})
const lastForPane = (): EnrichedAgentHookEventPayload =>
emitted.toReversed().find((event) => event.paneKey === PANE)!
it('holds the observation time across a reconnect replay while delivery order advances', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
expect(lastForPane().evidenceObservedAt).toBe(T0)
vi.setSystemTime(T0 + 25 * 60 * 1000)
// A lost transport clears the row; the age of the evidence it restates is not a claim.
server.clearStatusEntriesForConnection(CONNECTION)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
{ isReplay: true }
)
const replayed = lastForPane()
expect(replayed.payload.state).toBe('working')
// Delivery order must still clear the connection watermark, or the renderer drops the row.
expect(replayed.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1)
expect(replayed.evidenceObservedAt).toBe(T0)
})
it('lets a live event restamp the observation time after a replay', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
vi.setSystemTime(T0 + 25 * 60 * 1000)
server.clearStatusEntriesForConnection(CONNECTION)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
{ isReplay: true }
)
vi.setSystemTime(T0 + 26 * 60 * 1000)
ingest(server, { hook_event_name: 'PreToolUse', tool_name: 'Edit' })
expect(lastForPane().evidenceObservedAt).toBe(T0 + 26 * 60 * 1000)
})
it('gives a torn-down pane no inherited observation time', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
server.clearPaneState(PANE)
vi.setSystemTime(T0 + 25 * 60 * 1000)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'a new session' },
{ isReplay: true }
)
expect(lastForPane().evidenceObservedAt).toBe(T0 + 25 * 60 * 1000)
})
})
@@ -205,6 +205,144 @@ describe('AgentHookServer listener replay', () => {
expect(listener).toHaveBeenNthCalledWith(4, [])
})
it('evicts only the matching persisted status identity', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'resume-me' },
payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
},
'conn-1'
)
const old = server.getStatusSnapshot()[0]
expect(old).toBeDefined()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'new run', agentType: 'claude' }
},
'conn-1'
)
server.dropPersistedStatusEntry({
paneKey: old!.paneKey,
receivedAt: old!.receivedAt,
stateStartedAt: old!.stateStartedAt
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'new run' })
// A matching eviction follows ordinary dismissal semantics, including
// preserving a resumable provider session for the still-live TUI.
const resumed = new AgentHookServer()
resumed.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: 'resume-me' },
payload: { state: 'done', prompt: 'old run', agentType: 'claude' }
},
'conn-1'
)
const resumedIdentity = resumed.getStatusSnapshot()[0]!
expect(
resumed.dropPersistedStatusEntry({
paneKey: resumedIdentity.paneKey,
receivedAt: resumedIdentity.receivedAt,
stateStartedAt: resumedIdentity.stateStartedAt
})
).toBe(true)
expect(resumed.getStatusSnapshot()[0]).toMatchObject({
providerSessionOnly: true,
providerSession: { id: 'resume-me' }
})
})
it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => {
// Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is
// at or after main's receivedAt; the eviction must still land for those rows.
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
const entry = server.getStatusSnapshot()[0]!
expect(
server.dropPersistedStatusEntry({
paneKey: entry.paneKey,
receivedAt: entry.receivedAt + 5_000,
stateStartedAt: entry.stateStartedAt
})
).toBe(true)
// A different turn never matches, whatever the receivedAt relationship.
const other = new AgentHookServer()
other.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
const otherEntry = other.getStatusSnapshot()[0]!
expect(
other.dropPersistedStatusEntry({
paneKey: otherEntry.paneKey,
receivedAt: otherEntry.receivedAt + 5_000,
stateStartedAt: otherEntry.stateStartedAt + 1
})
).toBe(false)
})
it('evicts a batch of persisted identities with one status-change notification', () => {
const server = new AgentHookServer()
const otherPane = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
for (const paneKey of [PANE, otherPane]) {
server.ingestRemote(
{
paneKey,
tabId: paneKey.split(':')[0]!,
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
}
const listener = vi.fn()
server.subscribeStatusChanges(listener)
const dropped: string[] = []
server.subscribeStatusDrop((paneKey) => dropped.push(paneKey))
const identities = server.getStatusSnapshot().map((entry) => ({
paneKey: entry.paneKey,
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt
}))
const evicted = server.dropPersistedStatusEntries([
...identities,
// A stale identity never matches and never blocks the rest of the batch.
{ ...identities[0]!, stateStartedAt: identities[0]!.stateStartedAt + 1 }
])
expect(evicted.sort()).toEqual([PANE, otherPane].sort())
expect(dropped.sort()).toEqual([PANE, otherPane].sort())
expect(listener).toHaveBeenCalledTimes(1)
expect(server.getStatusSnapshot()).toEqual([])
})
it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => {
const server = new AgentHookServer()
const listener = vi.fn()
@@ -1,4 +1,5 @@
import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state'
import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerAuthorityFences } from './server-authority-fences'
@@ -35,6 +36,51 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
this.emitStatusDropped(deleted.paneKey)
}
/** Evict a UI-cleared status only if no newer status has replaced it. */
dropPersistedStatusEntry(identity: AgentStatusCacheIdentity): boolean {
return this.dropPersistedStatusEntries([identity]).length > 0
}
/** Batch form: one persist and one listener notification for the whole set. Returns the
* pane keys that were actually evicted. */
dropPersistedStatusEntries(identities: readonly AgentStatusCacheIdentity[]): string[] {
const evicted: string[] = []
for (const identity of identities) {
const resolvedPaneKey = this.resolvePaneKeyAlias(identity.paneKey)
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
// Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this
// receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly
// newer cached event is the only replacement worth protecting.
if (
!existing ||
existing.stateStartedAt !== identity.stateStartedAt ||
existing.receivedAt > identity.receivedAt
) {
continue
}
const deleted = this.deleteStatusEntry(resolvedPaneKey, { preserveAuthority: true })
if (!deleted) {
continue
}
const retained = this.toRetainedProviderSessionRow(deleted)
if (retained) {
this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
}
evicted.push(deleted.paneKey)
}
if (evicted.length === 0) {
return evicted
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
for (const paneKey of evicted) {
this.emitStatusDropped(paneKey)
}
return evicted
}
/** Retire panes whose owning process is certifiably dead.
*
* The ordinary teardown already does this: every attributable PTY exit reaches
@@ -97,6 +97,10 @@ export abstract class AgentHookServerState {
protected closedAgentStatusPaneKeys = new Set<string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
protected connectionTimestampWatermarkById = new Map<string, number>()
// Why: survives the row itself. A transport clear deletes the pane's status row on purpose
// (absence, not completion), but the *age* of the evidence a later replay restates is not a
// claim about the pane and must not be lost with it. Bounded like its sibling maps.
protected evidenceObservedAtByPaneKey = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
protected lastWrittenJson: string | null = null
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
@@ -13,6 +13,9 @@ import type { EnrichedAgentHookEventPayload } from './server-types'
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
import { AgentHookServerStatusDisposition } from './server-status-disposition'
/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */
const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024
export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition {
protected attachStatusTiming(
payload: AgentHookEventPayload,
@@ -41,10 +44,38 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
return {
...payload,
receivedAt: now,
evidenceObservedAt: this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt
}
}
/**
* A replay restates evidence already observed; it is not a new observation. Keeping
* `receivedAt` at `now` preserves delivery order (the connection-clear watermark and the
* renderer's four `<` drops all depend on it), while this clock records when the evidence
* was actually seen — so the staleness window measures age, not reconnect count.
* Without a remembered time the honest answer is `now`, which is today's behaviour.
*/
private resolveEvidenceObservedAt(
payload: AgentHookEventPayload,
previous: EnrichedAgentHookEventPayload | undefined,
now: number
): number {
const remembered =
previous?.evidenceObservedAt ?? this.evidenceObservedAtByPaneKey.get(payload.paneKey)
const observedAt = payload.isReplay === true && remembered !== undefined ? remembered : now
this.evidenceObservedAtByPaneKey.delete(payload.paneKey)
this.evidenceObservedAtByPaneKey.set(payload.paneKey, observedAt)
while (this.evidenceObservedAtByPaneKey.size > MAX_REMEMBERED_EVIDENCE_OBSERVATIONS) {
const oldest = this.evidenceObservedAtByPaneKey.keys().next().value
if (typeof oldest !== 'string') {
break
}
this.evidenceObservedAtByPaneKey.delete(oldest)
}
return observedAt
}
protected hashPromptForTelemetryDedupe(prompt: string): string {
return createHash('sha256')
.update(this.promptSentHashSalt)
@@ -94,6 +94,8 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
// Why: the pane itself is gone, so its observation clock describes nothing a later pane owns.
this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) {
if (alias.stablePaneKey === resolvedPaneKey) {
@@ -105,6 +107,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
this.evidenceObservedAtByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
@@ -11,6 +11,11 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-ty
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
/** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect
* replays cached rows and `receivedAt` must restamp to clear the connection watermark, so
* only this clock can answer how old the evidence itself is. Persisted so it survives a
* main restart; absent means "never separately observed" and consumers use `receivedAt`. */
evidenceObservedAt?: number
stateStartedAt: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn((..._args: unknown[]) => ({ pid: 1 }))
}))
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
import { spawnWslRelayProcess } from './wsl-hook-relay-launch'
describe('spawnWslRelayProcess', () => {
it('names an explicit Windows directory rather than inheriting one', () => {
spawnWslRelayProcess('Ubuntu', {}, '1.2.3')
// Why (#16463): the guest path is inside the `sh -c` command, so the Windows
// cwd only decides whether CreateProcessW succeeds. Omitting it inherits
// Orca's own — a `\\wsl.localhost` worktree the user can delete, after which
// every relay launch fails `spawn wsl.exe ENOENT` for the rest of the session.
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
expect.arrayContaining(['-d', 'Ubuntu', '--exec']),
expect.objectContaining({ cwd: expect.any(String) })
)
})
})

Some files were not shown because too many files have changed in this diff Show More