diff --git a/.gitattributes b/.gitattributes index f4676d210bb..97eeed155a2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,7 @@ -/config/scripts/create-draft-release.mjs text eol=lf -/config/scripts/orca-dev.mjs text eol=lf -/config/scripts/latest-stable-release.mjs text eol=lf -/config/scripts/publish-complete-draft-releases.mjs text eol=lf -/config/scripts/release-rc-history.mjs text eol=lf -/config/scripts/run-internal-dev-setup.mjs text eol=lf -/config/scripts/verify-cli-bin.mjs text eol=lf -/config/scripts/verify-release-required-assets.mjs text eol=lf +# A shebang plus CRLF makes vite's SSR transform emit a literal `#!` mid-module, +# so any suite importing the script dies at load with a SyntaxError. Pin the whole +# directory rather than the scripts that happen to have a test today. +/config/scripts/**/*.mjs text eol=lf /skill-guides/*.md text eol=lf /skill-stubs/*.md text eol=lf /skills/*/SKILL.md text eol=lf diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 80d3d42a8bb..50062161da6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -623,6 +623,8 @@ jobs: needs: [code_paths] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest + # Let the serial Docker gates reach their own deadlines and report cleanup failures. + timeout-minutes: 90 steps: - name: Checkout @@ -678,14 +680,45 @@ jobs: - name: Build native components run: pnpm run build:native + - name: Install Linux package tooling + run: sudo apt-get update && sudo apt-get install -y cpio rpm + - name: Package unpacked app env: ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' - run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never + run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish never + + - name: Verify root-package marker payloads + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + deb="dist/orca-ide_${version}_amd64.deb" + rpm="dist/orca-ide-${version}.x86_64.rpm" + test -s "$deb" + test -s "$rpm" + deb_marker="$(dpkg-deb --fsys-tarfile "$deb" | tar -xOf - ./opt/Orca/resources/package-type)" + rpm_marker="$(rpm2cpio "$rpm" | cpio --quiet --extract --to-stdout ./opt/Orca/resources/package-type)" + [[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; } + [[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; } - name: Verify headless serve signal shutdown run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage + - name: Verify extracted launcher serve signal shutdown + run: >- + node config/scripts/run-headless-serve-shutdown-docker.mjs + --appimage dist/orca-linux.AppImage --entrypoint launcher + + - name: Verify AppImage CLI registration and serve signal shutdown + run: >- + node config/scripts/run-headless-serve-shutdown-docker.mjs + --appimage dist/orca-linux.AppImage --entrypoint appimage + --signal-target serving-electron --int-delivery pid + + # A default container reproduces the hostile AppImage launch environment. + - name: Verify Linux CLI launch contract + run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage + - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked @@ -864,6 +897,9 @@ jobs: contents: read uses: ./.github/workflows/e2e.yml with: + # The synthetic pull-request merge ref can disappear while this reusable + # workflow is queued. The head SHA is immutable and works for every PR. + ref: ${{ github.event.pull_request.head.sha }} test_files: ${{ needs.e2e-paths.outputs.test_files }} ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }} diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 9bc7d415d7b..6f888c3a512 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -922,9 +922,7 @@ jobs: run: | $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' - pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden - pnpm run --if-present test:e2e:source-control-golden - name: Upload Playwright traces if: failure() @@ -940,6 +938,9 @@ jobs: if: needs.cut.outputs.should_release == 'true' name: skill sharing release gate ${{ matrix.platform }} runs-on: ${{ matrix.os }} + # The full suite is release-blocking on macOS. Windows still produces the + # same evidence, but intermittent filesystem contention cannot block signing. + continue-on-error: ${{ matrix.platform == 'windows' }} timeout-minutes: 20 strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index e3fd07e45d1..3fb72a6486a 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,7 @@ docs/** !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-edr-posture.md !docs/reference/windows-process-enumeration.md !docs/reference/wsl-runner-verification.md !docs/reference/remote-wire-compatibility.md diff --git a/AGENTS.md b/AGENTS.md index 9817cc41cc8..8b0156ba6b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md). +- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them. - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. diff --git a/README.md b/README.md index dfb676bcef5..0f99bfc877f 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,8 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan to join the Orca community WeChat group 7. If it is full, use group 8. +- **WeChat:** Scan to join the Orca community WeChat group 8. - WeChat group 7 QR code for the Orca community   WeChat group 8 QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). @@ -262,6 +261,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN

## Signed Builds + Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org). ## License diff --git a/config/docker/cli-launch-contract/Dockerfile b/config/docker/cli-launch-contract/Dockerfile new file mode 100644 index 00000000000..f6a618a8ece --- /dev/null +++ b/config/docker/cli-launch-contract/Dockerfile @@ -0,0 +1,34 @@ +ARG BASE_IMAGE=ubuntu:24.04 +FROM ${BASE_IMAGE} + +ARG LIBASOUND_PACKAGE=libasound2t64 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install Electron's link-time libraries without adding a display server or FUSE. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + coreutils \ + "${LIBASOUND_PACKAGE}" \ + libatk-bridge2.0-0 \ + libatspi2.0-0 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnss3 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + procps \ + util-linux \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash orca + +COPY run-cli-case.sh /usr/local/bin/run-cli-case + +ENTRYPOINT ["/usr/local/bin/run-cli-case"] diff --git a/config/docker/cli-launch-contract/run-cli-case.sh b/config/docker/cli-launch-contract/run-cli-case.sh new file mode 100755 index 00000000000..3293601f22f --- /dev/null +++ b/config/docker/cli-launch-contract/run-cli-case.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Print a parseable verdict; the host script owns expected statuses. +set -uo pipefail + +case_name=${1:?launch case is required} +extracted_root=${ORCA_TEST_EXTRACTED_ROOT:-/artifacts/squashfs-root} +launcher="$extracted_root/resources/bin/orca-ide" +command_timeout_seconds=${ORCA_TEST_COMMAND_TIMEOUT_SECONDS:-60} + +if ((EUID == 0)); then + # Reproduce extracted AppImage sandbox ownership as an unprivileged user. + exec runuser --user orca --preserve-environment -- "$0" "$@" +fi + +# Guard the restricted-userns precondition instead of accepting a false pass. +if [[ "$case_name" == *-userns-* ]]; then + if unshare -Ur true 2>/dev/null; then + echo "PRECONDITION_FAILED user namespaces are available; this case needs them restricted" + exit 90 + fi +fi +if [[ "$case_name" == nofuse-* && -e /dev/fuse ]]; then + echo "PRECONDITION_FAILED /dev/fuse is present; this case needs it absent" + exit 90 +fi + +unset DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR +if [[ "$case_name" == stale-display-* ]]; then + DISPLAY=:77 + export DISPLAY +fi + +case "$case_name" in + # The bundled launcher must stay in Electron's node mode. + nofuse-userns-bundled-help) command=("$launcher" --help) ;; + nofuse-userns-bundled-version) command=("$launcher" --version) ;; + nofuse-userns-bundled-status) command=("$launcher" status) ;; + nofuse-userns-bundled-skills) command=("$launcher" skills --help) ;; + nofuse-userns-bundled-worktree) command=("$launcher" worktree list) ;; + # Direct binaries must hand off before Ozone initializes. + nofuse-nosandbox-direct-binary-skills) + command=("$extracted_root/orca-ide" --no-sandbox skills --help) + ;; + nofuse-nosandbox-direct-binary-gui) + command=("$extracted_root/orca-ide" --no-sandbox) + ;; + stale-display-nosandbox-direct-binary-gui) + command=("$extracted_root/orca-ide" --no-sandbox) + ;; + *) + echo "UNKNOWN_CASE $case_name" + exit 91 + ;; +esac + +output=$(timeout --foreground --signal=TERM --kill-after=5s "${command_timeout_seconds}s" "${command[@]}" 2>&1) +status=$? + +if ((status == 124)); then + echo "TIMED_OUT seconds=$command_timeout_seconds case=$case_name" + printf '%s\n' "$output" | tail -30 + exit 94 +fi + +if [[ "$case_name" == nofuse-userns-bundled-version ]]; then + version_file="$extracted_root/resources/app.asar.unpacked/out/package.json" + expected_version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file") + if [[ -z "$expected_version" || "$output" != "$expected_version" ]]; then + output="VERSION_MISMATCH expected=${expected_version:-missing} got=$output" + status=93 + fi +fi + +# Shell signal exits are reported as 128 plus the signal number. +if ((status >= 128)); then + echo "CRASHED status=$status case=$case_name" + printf '%s\n' "$output" | tail -30 + exit 92 +fi + +echo "RESULT status=$status case=$case_name" +# Preserve the help header used by output assertions. +printf '%s\n' "$output" | head -200 +exit 0 diff --git a/config/docker/headless-pairing/Dockerfile b/config/docker/headless-pairing/Dockerfile index 8feafcc6e82..03664f68b0d 100644 --- a/config/docker/headless-pairing/Dockerfile +++ b/config/docker/headless-pairing/Dockerfile @@ -28,7 +28,6 @@ RUN apt-get update \ util-linux \ xauth \ xvfb \ - zlib1g-dev \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash orca diff --git a/config/docker/headless-serve-shutdown/Dockerfile b/config/docker/headless-serve-shutdown/Dockerfile index 669bb02b00a..13b1ed2b69f 100644 --- a/config/docker/headless-serve-shutdown/Dockerfile +++ b/config/docker/headless-serve-shutdown/Dockerfile @@ -22,16 +22,15 @@ RUN apt-get update \ libxkbcommon0 \ libxrandr2 \ libxss1 \ - p7zip-full \ procps \ util-linux \ xauth \ xvfb \ - zlib1g-dev \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash orca COPY run-signal-case.sh /usr/local/bin/run-signal-case +COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case ENTRYPOINT ["/usr/local/bin/run-signal-case"] diff --git a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh new file mode 100755 index 00000000000..59a6bef0e5c --- /dev/null +++ b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +appimage=${1:-/input/orca.AppImage} +startup_timeout_seconds=90 +if [[ $# -gt 1 ]]; then + echo "usage: run-appimage-desktop-startup-case.sh [appimage]" >&2 + exit 64 +fi + +if ((EUID == 0)); then + if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then + echo 'FAIL: unable to create the AppImage startup state directory' >&2 + exit 1 + fi + if ! chown orca:orca "$state_dir"; then + echo "FAIL: unable to hand the AppImage startup state directory to orca: $state_dir" >&2 + rm -rf -- "$state_dir" || true + exit 1 + fi + exec runuser --user orca --preserve-environment -- env \ + ORCA_STARTUP_STATE_DIR="$state_dir" \ + ORCA_STARTUP_STATE_DIR_CLEANUP=1 \ + "$0" "$@" +fi + +remove_state_dir_on_exit=${ORCA_STARTUP_STATE_DIR_CLEANUP:-0} +if [[ -n "${ORCA_STARTUP_STATE_DIR:-}" ]]; then + state_dir=$ORCA_STARTUP_STATE_DIR +else + if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then + echo 'FAIL: unable to create the AppImage startup state directory' >&2 + exit 1 + fi + remove_state_dir_on_exit=1 +fi +stdout_log="$state_dir/stdout.log" +stderr_log="$state_dir/stderr.log" +launcher_pid= +launcher_start_ticks= +launcher_pgid= +launcher_status= +launcher_waited=false +tree_pids=() +declare -A tree_start_ticks=() + +read_start_ticks() { + local pid=$1 + [[ -r "/proc/$pid/stat" ]] || return 1 + awk '{print $22}' "/proc/$pid/stat" +} + +identity_alive() { + local pid=$1 + local expected_ticks=$2 + [[ -n "$expected_ticks" ]] || return 1 + [[ -r "/proc/$pid/stat" ]] || return 1 + [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "$expected_ticks" ]] || return 1 + local process_state + process_state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true) + [[ -n "$process_state" && "$process_state" != Z* ]] +} + +collect_process_tree() { + tree_pids=() + tree_start_ticks=() + [[ -n "$launcher_pid" ]] || return + [[ -n "$launcher_start_ticks" ]] || return + tree_pids+=("$launcher_pid") + tree_start_ticks["$launcher_pid"]="$launcher_start_ticks" + local -a frontier=("$launcher_pid") + while ((${#frontier[@]})); do + local parent=${frontier[0]} + frontier=("${frontier[@]:1}") + while read -r child; do + [[ -n "$child" ]] || continue + [[ -z "${tree_start_ticks[$child]+present}" ]] || continue + local child_ticks + child_ticks=$(read_start_ticks "$child" 2>/dev/null || true) + [[ -n "$child_ticks" ]] || continue + tree_pids+=("$child") + tree_start_ticks["$child"]="$child_ticks" + frontier+=("$child") + done < <(ps -eo pid=,ppid= | awk -v parent="$parent" '$2 == parent {print $1}') + done +} + +process_is_xvfb() { + local pid=$1 + local command_name + command_name=$(ps -o comm= -p "$pid" 2>/dev/null || true) + [[ "$command_name" == Xvfb ]] && return 0 + local command_line + command_line=$(ps -o args= -p "$pid" 2>/dev/null || true) + [[ "$command_line" =~ (^|[[:space:]/])Xvfb([[:space:]]|$) ]] +} + +signal_process_group() { + local signal=$1 + identity_alive "$launcher_pid" "$launcher_start_ticks" || return 0 + [[ "$launcher_pgid" =~ ^[0-9]+$ ]] || return 0 + [[ "$launcher_pgid" != "$(ps -o pgid= -p "$$" | tr -d ' ')" ]] || return 0 + kill -s "$signal" -- "-$launcher_pgid" 2>/dev/null || true +} + +signal_owned_processes() { + local signal=$1 + local index pid ticks + for ((index = ${#tree_pids[@]} - 1; index >= 0; index--)); do + pid=${tree_pids[index]} + ticks=${tree_start_ticks[$pid]-} + if identity_alive "$pid" "$ticks"; then + kill -s "$signal" "$pid" 2>/dev/null || true + fi + done +} + +wait_for_owned_exit() { + local timeout_seconds=$1 + local deadline=$((SECONDS + timeout_seconds)) + local pid ticks alive + while ((SECONDS < deadline)); do + alive=0 + for pid in "${tree_pids[@]}"; do + ticks=${tree_start_ticks[$pid]-} + if identity_alive "$pid" "$ticks"; then + alive=1 + break + fi + done + if ((alive == 0)); then + return 0 + fi + sleep 0.2 + done + return 1 +} + +dump_logs() { + echo "--- desktop startup stdout ---" >&2 + cat "$stdout_log" >&2 2>/dev/null || true + echo "--- desktop startup stderr ---" >&2 + cat "$stderr_log" >&2 2>/dev/null || true +} + +cleanup_state_dir() { + [[ "$remove_state_dir_on_exit" == 1 ]] || return 0 + [[ "$state_dir" =~ ^/tmp/orca-appimage-startup\.[^/]+$ ]] || return 0 + [[ -d "$state_dir" && ! -L "$state_dir" && -O "$state_dir" ]] || return 0 + rm -rf -- "$state_dir" +} + +capture_launcher_status() { + [[ "$launcher_waited" == false ]] || return 0 + [[ -n "$launcher_pid" ]] || return 1 + if wait "$launcher_pid"; then + launcher_status=0 + else + launcher_status=$? + fi + launcher_waited=true +} + +report_launcher_exit() { + local reason=$1 + local observed_status=unknown + local exit_status=1 + if capture_launcher_status; then + observed_status=$launcher_status + if ((launcher_status != 0)); then + exit_status=$launcher_status + fi + fi + echo "FAIL: desktop launcher exited before ${reason} (status=${observed_status})" >&2 + exit "$exit_status" +} + +cleanup() { + local status=$? + trap - EXIT + signal_process_group TERM || true + signal_owned_processes TERM || true + if ! wait_for_owned_exit 10; then + signal_process_group KILL || true + signal_owned_processes KILL || true + wait_for_owned_exit 5 || status=1 + fi + capture_launcher_status || true + if ((status != 0)); then + dump_logs + else + if ! cleanup_state_dir; then + status=1 + dump_logs + fi + fi + exit "$status" +} +trap cleanup EXIT + +mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime" +chmod 700 "$state_dir/runtime" +export HOME="$state_dir/home" +export XDG_CONFIG_HOME="$state_dir/config" +export XDG_CACHE_HOME="$state_dir/cache" +export XDG_RUNTIME_DIR="$state_dir/runtime" +export LIBGL_ALWAYS_SOFTWARE=1 +export ORCA_STARTUP_DIAGNOSTICS=1 +ulimit -c 0 + +[[ -r "$appimage" ]] || { echo "FAIL: AppImage is not readable: $appimage" >&2; exit 1; } +[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; } + +setsid --wait dbus-run-session -- xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox \ + >"$stdout_log" 2>"$stderr_log" & +launcher_pid=$! +launcher_start_ticks=$(read_start_ticks "$launcher_pid" 2>/dev/null || true) +launcher_pgid=$(ps -o pgid= -p "$launcher_pid" 2>/dev/null | tr -d ' ' || true) +if [[ -z "$launcher_start_ticks" ]]; then + report_launcher_exit 'its identity could be recorded' +fi + +marker_seen=false +deadline=$((SECONDS + startup_timeout_seconds)) +while ((SECONDS < deadline)); do + if grep -Eq '^\[startup\] updater-setup-done t=[0-9]+$' "$stderr_log"; then + marker_seen=true + break + fi + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'the updater-setup-done marker' + fi + sleep 0.2 +done +if [[ "$marker_seen" != true ]]; then + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'the updater-setup-done marker' + fi + echo "FAIL: desktop AppImage did not emit updater-setup-done within ${startup_timeout_seconds}s" >&2 + exit 1 +fi +if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + echo "FAIL: desktop launcher identity changed after startup marker" >&2 + exit 1 +fi + +collect_process_tree +xvfb_pids=() +for pid in "${tree_pids[@]}"; do + if process_is_xvfb "$pid"; then + xvfb_pids+=("$pid") + fi +done +if ((${#xvfb_pids[@]} == 0)); then + echo "FAIL: no launcher-owned Xvfb process was found after startup" >&2 + exit 1 +fi +for pid in "${xvfb_pids[@]}"; do + if ! identity_alive "$pid" "${tree_start_ticks[$pid]-}"; then + echo "FAIL: launcher-owned Xvfb identity changed before cleanup" >&2 + exit 1 + fi +done + +echo "Desktop AppImage startup validation passed (launcher=${launcher_pid}, xvfb=${xvfb_pids[*]})." diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh index 3cbf594ae1e..2d561629170 100755 --- a/config/docker/headless-serve-shutdown/run-signal-case.sh +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -6,7 +6,9 @@ app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root} signal_target_kind=${ORCA_SIGNAL_TARGET:-app} entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app} int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group} -startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90} +# Packaged Electron startup can approach 90s on a cold CI runner; leave room +# for the readiness line to reach the log before the observer deadline. +startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180} if ((EUID == 0)); then exec runuser --user orca --preserve-environment -- "$0" "$@" @@ -41,8 +43,8 @@ chmod 700 "$XDG_RUNTIME_DIR" case "$entrypoint_kind" in app) entrypoint=("$app_root/AppRun" --no-sandbox) ;; + appimage) entrypoint=(/input/orca.AppImage --appimage-extract-and-run --no-sandbox) ;; launcher) - export ELECTRON_DISABLE_SANDBOX=1 entrypoint=("$app_root/resources/bin/orca-ide") ;; *) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;; @@ -53,18 +55,50 @@ setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0. app_pid=$! app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat") -# The inner shell expands its positional parameters. -# shellcheck disable=SC2016 -ready_line=$(timeout "$startup_timeout_seconds" bash -c ' - tail --pid="$1" -n +1 -F "$2" 2>/dev/null \ - | jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\'' -' bash "$app_pid" "$stdout_log" || true) +# jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F +# observer can outlive the timeout and leak into the next signal case. Poll +# finite snapshots instead; each parser invocation has a definite EOF. +read_ready_line() { + sed -u -n 's/^[^{]*//p' "$stdout_log" \ + | jq --unbuffered -Rnc 'first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))' +} + +ready_line='' +startup_deadline=$((SECONDS + startup_timeout_seconds)) +while (( SECONDS < startup_deadline )); do + ready_line=$(read_ready_line) + [[ -n "$ready_line" ]] && break + kill -0 "$app_pid" 2>/dev/null || break + sleep 1 +done +# A readiness event can land as the final poll races the write. +if [[ -z "$ready_line" ]]; then + ready_line=$(read_ready_line) +fi if [[ -z "$ready_line" ]]; then cat "$stdout_log" "$stderr_log" >&2 - echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2 + echo "FAIL: entrypoint exited or timed out before orca_server_ready" >&2 exit 1 fi +registered_cli_verified=false +if [[ "$entrypoint_kind" == appimage ]]; then + registered_cli="$HOME/.local/bin/orca-ide" + expected_target="$XDG_CACHE_HOME/orca/appimage/launcher/orca-ide" + actual_target=$(readlink "$registered_cli" 2>/dev/null || true) + if [[ "$actual_target" != "$expected_target" ]]; then + echo "FAIL: registered CLI target is ${actual_target:-missing}; expected $expected_target" >&2 + exit 1 + fi + if ! registered_help=$("$registered_cli" --help 2>&1) \ + || [[ "$registered_help" != *'Usage: orca '* ]]; then + echo "FAIL: registered CLI did not execute the packaged help command" >&2 + printf '%s\n' "$registered_help" >&2 + exit 1 + fi + registered_cli_verified=true +fi + bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line") bound_port=${bound_endpoint##*:} listener_before=$(ss -H -ltnp "sport = :$bound_port" || true) @@ -72,6 +106,7 @@ if [[ -z "$listener_before" ]]; then echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2 exit 1 fi +listener_before_pids=$(grep -oE 'pid=[0-9]+' <<<"$listener_before" | cut -d= -f2 || true) tree_pids=() declare -A tree_start_ticks @@ -104,8 +139,15 @@ fi signal_target_pid=$app_pid if [[ "$signal_target_kind" == serving-electron ]]; then - signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot") + # The ready socket identifies the serving Electron even when AppImage's + # extraction wrapper rewrites the command line before it reaches Chromium. + signal_target_pid=$(head -n1 <<<"$listener_before_pids") [[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; } + if [[ -z "${tree_start_ticks[$signal_target_pid]+present}" ]]; then + echo "FAIL: ready listener PID $signal_target_pid is outside the entrypoint process tree" >&2 + echo "listener: $listener_before" >&2 + exit 1 + fi elif [[ "$signal_target_kind" != app ]]; then echo "unsupported signal target: $signal_target_kind" >&2 exit 64 @@ -138,17 +180,25 @@ fi kill "$watchdog_pid" 2>/dev/null || true wait "$watchdog_pid" 2>/dev/null || true -listener_after=$(ss -H -ltnp "sport = :$bound_port" || true) -survivors=() -for pid in "${tree_pids[@]}"; do - if [[ -r "/proc/$pid/stat" ]] \ - && [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \ - && ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then - survivors+=("$pid") +# Crashpad can exit just after Electron; poll all owned shutdown state for up to 5s. +for shutdown_poll in {0..50}; do + listener_after=$(ss -H -ltnp "sport = :$bound_port" || true) + survivors=() + for pid in "${tree_pids[@]}"; do + if [[ -r "/proc/$pid/stat" ]] \ + && [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \ + && ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then + survivors+=("$pid") + fi + done + owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \ + '($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true) + if [[ -z "$listener_after" && -z "$owned_residue" ]] \ + && ((${#survivors[@]} == 0)); then + break fi + ((shutdown_poll < 50)) && sleep 0.1 done -owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \ - '($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true) canary_alive=false if kill -0 "$canary_pid" 2>/dev/null \ @@ -170,16 +220,18 @@ jq -nc \ --argjson signalTargetPid "$signal_target_pid" \ --arg endpoint "$bound_endpoint" \ --arg listenerBefore "$listener_before" \ + --arg listenerBeforePids "$listener_before_pids" \ --arg listenerAfter "$listener_after" \ --arg xvfbPids "$xvfb_pids" \ --arg treeBefore "$tree_snapshot" \ --argjson waitStatus "$wait_status" \ --argjson fatalEvidence "$fatal_evidence" \ --argjson canaryAlive "$canary_alive" \ + --argjson registeredCliVerified "$registered_cli_verified" \ --arg survivors "${survivors[*]:-}" \ --arg residue "$owned_residue" \ --arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \ - '{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}' + '{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerBeforePids:$listenerBeforePids,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,registeredCliVerified:$registeredCliVerified,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}' if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \ || [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 13633ed8e01..06d41bad344 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -1,4 +1,4 @@ -const { chmodSync, existsSync, readdirSync } = require('node:fs') +const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') @@ -18,6 +18,7 @@ const { verifyPackagedNodePtyJobOwnership } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') +const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -104,6 +105,29 @@ const winSpeechNativeResource = { from: 'node_modules/sherpa-onnx-win-x64', to: 'node_modules/sherpa-onnx-win-x64' } +// electron-builder replaces these defaults when `depends` is configured; retain +// Electron's loader requirements alongside Orca's headless-host dependencies. +const debElectronRuntimeDependencies = [ + 'libgtk-3-0', + 'libnotify4', + 'libnss3', + 'libxss1', + 'libxtst6', + 'xdg-utils', + 'libatspi2.0-0', + 'libuuid1', + 'libsecret-1-0' +] +const rpmElectronRuntimeDependencies = [ + 'gtk3', + 'libnotify', + 'nss', + 'libXScrnSaver', + '(libXtst or libXtst6)', + 'xdg-utils', + 'at-spi2-core', + '(libuuid or libuuid1)' +] // Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. // Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with @@ -115,6 +139,7 @@ module.exports = { appId, productName: 'Orca', protocols: [{ name: 'Orca', schemes: ['orca'] }], + toolsets: { appimage: '1.0.3' }, ...(devChannelBuildVersion ? { extraMetadata: { version: devChannelBuildVersion } } : localBuildVersion @@ -235,12 +260,21 @@ module.exports = { 'node_modules/zod/**', 'node_modules/yaml/**' ], + artifactBuildCompleted: ({ file, arch }) => { + if (file.endsWith('.AppImage')) { + verifyStaticAppImagePackage(file, arch) + } + }, afterPack: async (context) => { // Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node // requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902). // Fail packaging if any bundled native binary exceeds the supported floor. if (context.electronPlatformName === 'linux') { - verifyLinuxGlibcFloor(context.appOutDir) + // Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary, + // so a cross-built slice could ship the host's pty.node and only fail at runtime. + verifyLinuxGlibcFloor(context.appOutDir, { + targetArch: { 1: 'x64', 3: 'arm64' }[context.arch] + }) } const resourcesDir = context.electronPlatformName === 'darwin' @@ -254,6 +288,10 @@ module.exports = { if (!existsSync(resourcesDir)) { throw new Error(`Missing packaged resources directory: ${resourcesDir}`) } + // FpmTarget replaces this with deb/rpm while building those artifacts from the shared app tree. + if (context.electronPlatformName === 'linux') { + writeFileSync(join(resourcesDir, 'package-type'), 'AppImage') + } if (context.electronPlatformName === 'darwin') { const architectureByEnum = { 1: 'x64', 3: 'arm64' } const architecture = architectureByEnum[context.arch] @@ -273,6 +311,7 @@ module.exports = { } writeMacBuildCompatibility(resourcesDir, { version, commit, architecture }) } + stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version) prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) verifyPackagedMainRuntimeDeps(resourcesDir) // Why: boot the packaged daemon-entry under plain Node, but only for the @@ -522,7 +561,8 @@ module.exports = { }, featureWallResources ], - target: ['AppImage', 'deb'], + // Keep local artifacts aligned with the release pipeline. + target: ['AppImage', 'deb', 'rpm'], maintainer: 'stablyai', category: 'Utility' }, @@ -536,6 +576,7 @@ module.exports = { // Linux host — Chromium needs a display server even for offscreen rendering, // and serve starts Xvfb itself when present (see ensure-virtual-display.ts). depends: [ + ...debElectronRuntimeDependencies, 'python3', 'python3-gi', 'gir1.2-atspi-2.0', @@ -557,9 +598,9 @@ module.exports = { // Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there // is no `xvfb` package), so the name differs from the deb here. depends: [ + ...rpmElectronRuntimeDependencies, 'python3', 'python3-gobject', - 'at-spi2-core', 'xdotool', 'xclip', 'xorg-x11-server-Xvfb' @@ -584,6 +625,16 @@ module.exports = { } } +// Stamp the effective channel version where node-mode CLI code can read it. +function stampPackagedCliVersion(resourcesDir, version) { + const packageJsonPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json') + if (!existsSync(packageJsonPath)) { + throw new Error(`Missing unpacked CLI package boundary: ${packageJsonPath}`) + } + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + writeFileSync(packageJsonPath, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`) +} + function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) { if (electronPlatformName === 'win32') { return diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 9ee2ebd39b4..348ce6ef7ce 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -176,7 +176,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd process.send!({ consoleProcessList }); process.exit(0); diff --git a/src/unix/pty.cc b/src/unix/pty.cc -index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644 +index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a52c690e0a 100644 --- a/src/unix/pty.cc +++ b/src/unix/pty.cc @@ -23,7 +23,9 @@ @@ -215,7 +215,17 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d /* Some platforms name VWERASE and VDISCARD differently */ #if !defined(VWERASE) && defined(VWERSE) #define VWERASE VWERSE -@@ -237,13 +258,23 @@ pty_getproc(int, char *); +@@ -228,6 +249,9 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info); + static int + pty_nonblock(int); + ++static int ++pty_cloexec(int); ++ + #if defined(__APPLE__) + static char * + pty_getproc(int); +@@ -237,13 +261,23 @@ pty_getproc(int, char *); #endif #if defined(__APPLE__) || defined(__OpenBSD__) @@ -240,7 +250,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d #endif struct DelBuf { -@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { +@@ -367,14 +401,18 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { argv[i + 3] = strdup(arg.c_str()); } @@ -256,7 +266,48 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d } if (pty_nonblock(master) == -1) { throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); -@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) { + } ++ if (pty_cloexec(master) == -1) { ++ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); ++ } + #else + int argc = argv_.Length(); + int argl = argc + 2; +@@ -445,6 +483,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } ++ if (pty_cloexec(master) == -1) { ++ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); ++ } + } + #endif + +@@ -586,6 +627,23 @@ pty_nonblock(int fd) { + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); + } + ++/** ++ * Orca: close-on-exec FD ++ * ++ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without ++ * FD_CLOEXEC is inherited by every later child of this process -- including ++ * later pty children -- which keeps its /dev/pts device and buffers alive long ++ * after its own session ends (#8362). ++ */ ++ ++static int ++pty_cloexec(int fd) { ++ int flags = fcntl(fd, F_GETFD); ++ if (flags == -1) return -1; ++ if (flags & FD_CLOEXEC) return 0; ++ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); ++} ++ + /** + * pty_getproc + * Taken from tmux. +@@ -684,15 +742,73 @@ pty_getproc(int fd, char *tty) { #endif #if defined(__APPLE__) @@ -332,7 +383,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d for (; count < 3; count++) { low_fds[count] = posix_openpt(O_RDWR); -@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env, +@@ -706,80 +822,118 @@ pty_posix_spawn(char** argv, char** env, POSIX_SPAWN_SETSID; *master = posix_openpt(O_RDWR); if (*master == -1) { diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs new file mode 100644 index 00000000000..f4f4f87619a --- /dev/null +++ b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs @@ -0,0 +1,318 @@ +/** + * Relay-side pty-master close-on-exec patch for node-pty 1.1.0 (#17915). + * + * The app gets this through pnpm `patchedDependencies`; the relay installs stock + * node-pty from npm onto the host, where no pnpm patch reaches. Without it every + * later child of the relay -- pty children, git helpers, probes, agent CLIs -- + * inherits each live master fd and keeps its /dev/pts device alive for the life + * of the relay (#8362). + * + * Linux only, deliberately: it is the only relay platform that takes forkpty()'s + * no-atomic-O_CLOEXEC path, and the only one that already compiles node-pty at + * install time, so the rebuild costs a second compile rather than a first one. + * macOS re-opens the tty through uv_tty_init's cloexec dup and Windows has no fds. + * + * Non-fatal by construction: the working build is moved aside before anything is + * touched and moved back on any failure, and a failed attempt drops a skip marker + * so the compile is attempted at most once per relay directory. + */ + +const { spawnSync } = require('node:child_process') +const { createHash } = require('node:crypto') +const { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} = require('node:fs') +const { dirname, join, resolve } = require('node:path') + +const EXPECTED_NODE_PTY_VERSION = '1.1.0' +const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6' +const PATCHED_SOURCE_SHA256 = '97dea52199216c01b62070758f0f38621ae53adc16c221271dd35ae2d8ee3482' + +const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' +const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip' +const BACKUP_DIRNAME = '.orca-cloexec-prepatch-release' +// Under the caller's 240s SSH command timeout, so the rollback below still runs. +const REBUILD_TIMEOUT_MS = 200000 +const VERIFY_TIMEOUT_MS = 15000 + +const FORWARD_DECLARATION = [ + 'static int\npty_nonblock(int);\n', + 'static int\npty_nonblock(int);\n\nstatic int\npty_cloexec(int);\n' +] + +const DEFINITION = [ + `static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} +`, + `static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +/** + * Orca: close-on-exec FD + * + * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without + * FD_CLOEXEC is inherited by every later child of this process -- including + * later pty children -- which keeps its /dev/pts device and buffers alive long + * after its own session ends (#8362). + */ + +static int +pty_cloexec(int fd) { + int flags = fcntl(fd, F_GETFD); + if (flags == -1) return -1; + if (flags & FD_CLOEXEC) return 0; + return fcntl(fd, F_SETFD, flags | FD_CLOEXEC); +} +` +] + +const FORKPTY_CALL_SITE = [ + ` default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + } +`, + ` default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + if (pty_cloexec(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); + } + } +` +] + +const REPLACEMENTS = [FORWARD_DECLARATION, DEFINITION, FORKPTY_CALL_SITE] + +function sourceSha256(source) { + return createHash('sha256').update(source).digest('hex') +} + +function nodePtyDir(relayDir) { + return resolve(relayDir, 'node_modules', 'node-pty') +} + +function inspectNodePtyUnixSource(relayDir) { + const ptyDir = nodePtyDir(relayDir) + const sourcePath = join(ptyDir, 'src', 'unix', 'pty.cc') + const version = JSON.parse(readFileSync(join(ptyDir, 'package.json'), 'utf8')).version + if (version !== EXPECTED_NODE_PTY_VERSION) { + throw new Error(`Refusing to patch node-pty ${version}; expected ${EXPECTED_NODE_PTY_VERSION}`) + } + return { ptyDir, sourcePath, source: readFileSync(sourcePath, 'utf8') } +} + +function writeSourceAtomically(sourcePath, contents) { + const temporaryPath = `${sourcePath}.orca-patch-${process.pid}` + // Why: a terminated install must leave one of the two known source versions on disk. + try { + writeFileSync(temporaryPath, contents) + renameSync(temporaryPath, sourcePath) + } finally { + rmSync(temporaryPath, { force: true }) + } +} + +function rewriteSource(source, reverse) { + let rewritten = source + for (const [original, patched] of REPLACEMENTS) { + const from = reverse ? patched : original + const to = reverse ? original : patched + if (rewritten.split(from).length - 1 !== 1) { + throw new Error('Refusing to rewrite unexpected node-pty pty.cc source') + } + rewritten = rewritten.replace(from, to) + } + return rewritten +} + +/** True when the patch was applied, false when it was already installed. */ +function patchNodePtyMasterCloexecSource(relayDir = process.cwd()) { + const inspected = inspectNodePtyUnixSource(relayDir) + const hash = sourceSha256(inspected.source) + if (hash === PATCHED_SOURCE_SHA256) { + return false + } + if (hash !== ORIGINAL_SOURCE_SHA256) { + throw new Error('Refusing to patch unexpected node-pty pty.cc source') + } + writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, false)) + assertPatchedNodePtyMasterCloexecSource(relayDir) + return true +} + +function assertPatchedNodePtyMasterCloexecSource(relayDir = process.cwd()) { + const inspected = inspectNodePtyUnixSource(relayDir) + if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) { + throw new Error('node-pty pty master close-on-exec patch is not installed') + } +} + +function revertNodePtyMasterCloexecSource(relayDir = process.cwd()) { + const inspected = inspectNodePtyUnixSource(relayDir) + if (sourceSha256(inspected.source) === ORIGINAL_SOURCE_SHA256) { + return false + } + writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, true)) + return true +} + +function rebuildNodePty(relayDir) { + const result = spawnSync('npm', ['rebuild', '--ignore-scripts=false', 'node-pty'], { + cwd: relayDir, + encoding: 'utf8', + timeout: REBUILD_TIMEOUT_MS, + windowsHide: true + }) + if (result.error) { + throw new Error(`npm rebuild node-pty failed: ${result.error.message}`) + } + if (result.status !== 0) { + const tail = `${result.stdout || ''}${result.stderr || ''}`.trim().slice(-300) + throw new Error(`npm rebuild node-pty exited ${result.status ?? result.signal}: ${tail}`) + } +} + +// Why a child: a bad build can abort the process on require, which would strand the +// moved-aside working build. Why the reachability check: a host without /proc cannot +// show inheritance, and an unobservable flag is not evidence the rebuild was wrong. +const VERIFY_SCRIPT = ` +const pty = require(process.argv[1]); +const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env +}); +const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l /proc/self/fd'], { encoding: 'utf8' }); +try { term.kill() } catch {} +const listing = probe.stdout || ''; +if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) } +console.log(listing.includes('ptmx') ? 'INHERITED' : 'ISOLATED'); +process.exit(0); +` + +/** 'isolated' when a later plain child no longer inherits the master, 'unverified' when /proc cannot say. */ +function verifyMasterNotInheritedByLaterChild(relayDir) { + const result = spawnSync(process.execPath, ['-e', VERIFY_SCRIPT, nodePtyDir(relayDir)], { + cwd: relayDir, + encoding: 'utf8', + timeout: VERIFY_TIMEOUT_MS, + windowsHide: true + }) + const output = `${result.stdout || ''}` + if (result.status !== 0 || result.error) { + const tail = `${output}${result.stderr || ''}`.trim().slice(-300) + throw new Error( + `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}` + ) + } + if (output.includes('INHERITED')) { + throw new Error('rebuilt node-pty still leaks the pty master into later children') + } + return output.includes('ISOLATED') ? 'isolated' : 'unverified' +} + +function rollback(relayDir, releaseDir, backupDir) { + rmSync(releaseDir, { recursive: true, force: true }) + try { + revertNodePtyMasterCloexecSource(relayDir) + } catch { + // The build that is about to be restored predates the patch either way. + } + if (existsSync(backupDir)) { + mkdirSync(dirname(releaseDir), { recursive: true }) + renameSync(backupDir, releaseDir) + } +} + +/** + * Patch and rebuild the host's node-pty, or leave it exactly as found. + * Never throws: the caller is on the connect path and a leaky relay beats no relay. + */ +function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) { + const platform = options.platform || process.platform + const rebuild = options.rebuild || rebuildNodePty + const verify = options.verify || verifyMasterNotInheritedByLaterChild + if (platform !== 'linux') { + return 'skipped:not-linux' + } + const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME) + if (existsSync(skipMarkerPath)) { + return 'skipped:earlier-attempt-failed' + } + const releaseDir = join(nodePtyDir(relayDir), 'build', 'Release') + const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME) + // A backup stranded by a connection that died mid-rebuild is stale by definition: + // whatever repaired node-pty since built from the source now on disk. + rmSync(backupDir, { recursive: true, force: true }) + + let inspected + try { + inspected = inspectNodePtyUnixSource(relayDir) + } catch (err) { + return `skipped:${err.message}` + } + const hash = sourceSha256(inspected.source) + if (hash === PATCHED_SOURCE_SHA256) { + return 'already-patched' + } + if (hash !== ORIGINAL_SOURCE_SHA256) { + return 'skipped:unexpected-source' + } + // No compiled build means the host runs a prebuild or nothing at all; rebuilding + // could only take away the artifact the probe just proved loadable. + if (!existsSync(join(releaseDir, 'pty.node'))) { + return 'skipped:no-compiled-build' + } + + try { + renameSync(releaseDir, backupDir) + } catch (err) { + return `skipped:${err.message}` + } + try { + patchNodePtyMasterCloexecSource(relayDir) + rebuild(relayDir) + const verdict = verify(relayDir) + rmSync(backupDir, { recursive: true, force: true }) + return verdict === 'isolated' ? 'patched' : 'patched-unverified' + } catch (err) { + rollback(relayDir, releaseDir, backupDir) + // Bounded on purpose: one compile attempt per relay directory, never a retry loop. + try { + writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`) + } catch { + // A relay dir we cannot write to will fail the cheap checks above next time anyway. + } + return `failed:${err.message}` + } +} + +if (require.main === module) { + console.log(`${STATUS_PREFIX}${applyNodePtyMasterCloexecPatch()}`) +} + +module.exports = { + EXPECTED_NODE_PTY_VERSION, + ORIGINAL_SOURCE_SHA256, + PATCHED_SOURCE_SHA256, + SKIP_MARKER_FILENAME, + STATUS_PREFIX, + applyNodePtyMasterCloexecPatch, + assertPatchedNodePtyMasterCloexecSource, + patchNodePtyMasterCloexecSource, + revertNodePtyMasterCloexecSource +} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0c2337ba36f..c36412c0383 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13863,6 +13863,90 @@ "knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."], "demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped." }, + { + "id": "terminal-render.foreground-repair-span", + "title": "A forced foreground repaint covers every row the write changed", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "foreground PTY output", + "in-place agent redraws", + "erase-in-line/display", + "alternate screen", + "scroll", + "wide glyphs" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Renderer-unit convergence corpus over a real xterm parser, plus manual CDP pixel evidence on the macOS WebGL renderer. The repaint span is provider-independent because it is computed from xterm's parse, not from the transport; SSH/WSL/remote were not exercised live.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/2669", + "https://github.com/stablyai/orca/pull/4669", + "https://github.com/stablyai/orca/pull/8178" + ], + "invariant": "The row span Orca asks xterm to repaint after a forced foreground refresh must cover every viewport row whose rendered content changed during that write, plus the cursor row before and after it; when the span cannot be established — unobservable parse, viewport scroll, or a normal/alternate buffer flip — the whole viewport must be repainted.", + "oracle": "A real @xterm/headless parser replays an adversarial corpus (in-place bottom-row redraws, standalone CR overwrite, backspace, erase-in-line, erase-in-display above and below the cursor, full clear, wide CJK, emoji, combining marks, ZWJ sequences, scroll-region insert/delete, reverse index, DEC 2026 frames, alternate-screen enter and exit, viewport scroll, narrow panes). Each viewport row is serialized cell-by-cell with its attributes before and after the write, and every row that differs must fall inside the span the settle path requested. A vacuity guard asserts each case actually moves the screen.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts", + "assertions": [ + "every viewport row whose serialized cells changed lies inside the requested repaint span", + "the cursor row before and after the write is inside the requested repaint span", + "viewport scroll and alternate-screen transitions still request the whole grid", + "an unobservable parse span falls back to the whole grid", + "an in-place bottom-row redraw narrows well below the full grid" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-02", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts", + "durationSeconds": 1, + "summary": "25 cases passed against a real xterm parser; paired CDP run on a 4-pane macOS WebGL dev build produced screenshots byte-identical to a forced full model rebuild." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Renderer-unit convergence corpus" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "New deterministic gate; no soak history yet." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Narrowing the span to the cursor rows alone (dropping xterm's parse span) fails the claude-style in-place redraw and erase-in-display-above cases; reading buffer indices instead of viewport rows made the corpus vacuous and is now blocked by the changed-row guard." + }, + "performanceBudget": { + "required": true, + "evidence": "Measured on a focused, visible 4-pane macOS dev build under an agent-style in-place redraw load: rendered cells/s 157,708 -> 30,139 and forEachDecorationAtCell 320,868/s -> 60,652/s with render frames/s unchanged (59.8 -> 60.5)." + }, + "promotionCriteria": [ + "Add Windows DOM-renderer coverage for the synchronous repair branch.", + "Wire pixel or cell evidence for the alternate-screen and reflow paths into CI rather than manual CDP runs.", + "Keep a full-grid fallback assertion for every new span-narrowing condition." + ], + "knownGaps": [ + "No CI-wired pixel oracle; WebGL convergence evidence was collected manually over CDP.", + "Windows ConPTY synchronous repair path is covered only by the shared corpus, not on a Windows runner.", + "SSH/WSL/remote providers were not exercised live; the span is transport-independent by construction." + ], + "demotionRule": "Demote or block if a narrowing condition is added without a matching convergence case, if the corpus stops asserting that each case changes at least one row, or if a repaint regression is reported for in-place agent redraws." + }, { "id": "terminal-shell.windows-resolution-parity", "title": "Windows local and daemon providers resolve shells and startup commands consistently", @@ -16701,16 +16785,17 @@ "providers": ["local-daemon"], "coveredPlatforms": ["linux"], "coveredProviders": ["local-daemon"], - "coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.", + "coverageNotes": "An Ubuntu 26.04 amd64 container first launches the original AppImage through dbus-run-session and xvfb-run with startup diagnostics, then extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, and exercises terminal-style foreground-process-group SIGINT plus the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same startup and foreground-AppRun identity contracts.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/14109", "https://linear.app/stably/issue/STA-4051" ], "invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.", - "oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.", + "oracle": "First start the original, readable-and-executable AppImage once in a fresh restricted Ubuntu 26.04 container through dbus-run-session -- xvfb-run -a --appimage-extract-and-run with ORCA_STARTUP_DIAGNOSTICS=1, and require the exact updater-setup-done marker within 90 seconds while fencing the launcher and owned Xvfb by PID start ticks. For each signal, start a separate unprivileged container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot", "shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh", + "shellcheck config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh", "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage", "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64" ], @@ -16718,7 +16803,8 @@ "src/main/startup/ensure-virtual-display.test.ts", "config/scripts/headless-serve-shutdown-workflow.test.mjs", "config/scripts/run-headless-serve-shutdown-docker.mjs", - "config/docker/headless-serve-shutdown/run-signal-case.sh" + "config/docker/headless-serve-shutdown/run-signal-case.sh", + "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh" ], "assertionRefs": [ { @@ -16735,15 +16821,19 @@ "file": "config/scripts/headless-serve-shutdown-workflow.test.mjs", "assertions": [ "PR CI builds an x64 AppImage before invoking the packaged shutdown oracle", + "the original AppImage desktop startup oracle is wired before extraction and signal cases", + "the bound AppImage is readable and executable before desktop launch and extraction", "the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb" ] }, { "file": "config/scripts/run-headless-serve-shutdown-docker.mjs", "assertions": [ + "the original AppImage startup runs through dbus-run-session and xvfb-run with a bounded diagnostics marker", "SIGINT and SIGTERM run in separate disposable containers", "both signal failures are reported before the oracle exits", "the exact AppImage SHA-256, entrypoint, and signal target are published", + "the read-only AppImage bind is checked for read and execute permissions before extraction", "the launcher exec overlay isolates the related STA-4017 signal boundary" ] }, @@ -16754,6 +16844,14 @@ "SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy", "target and descendant identities are fenced by PID start ticks before signaling and residue checks" ] + }, + { + "file": "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh", + "assertions": [ + "the original AppImage emits the exact updater-setup-done startup marker within 90 seconds", + "launcher and owned Xvfb identities are fenced by PID start ticks", + "cleanup sends bounded TERM then KILL signals and preserves failure logs" + ] } ], "evidenceRuns": [ @@ -16774,11 +16872,20 @@ "result": "passed", "durationSeconds": 48, "summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity." + }, + { + "date": "2026-08-31", + "runner": "ci", + "platform": "linux", + "command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64", + "result": "passed", + "durationSeconds": 1336, + "summary": "Native-amd64 PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 built AppImage SHA-256 999d43bfe123e87a77fe917a5f46be1efd5c45a5205f99f02998a75136d8a793 and ran the restricted original-AppImage startup oracle before each of the three signal matrices. Each startup reached the exact updater-setup-done marker with stable launcher/Xvfb PID-start-tick identities and bounded TERM/KILL cleanup; SIGINT and SIGTERM then returned wait status 0 with no fatal evidence, listener, descendant, Xvfb, or canary residue. This is CI evidence only; no fresh local Docker oracle is claimed." } ], "runtimeBudget": { - "p95Seconds": 240, - "scope": "two fresh Ubuntu 26.04 containers, one per foreground signal" + "p95Seconds": 1800, + "scope": "three AppImage startup/extraction matrices, each with fresh Ubuntu 26.04 INT and TERM containers" }, "flakeHistory": { "status": "not-started", @@ -16805,6 +16912,105 @@ ], "demotionRule": "Keep experimental or demote if either signal traps, returns nonzero, retains its listener/Xvfb/run-owned process identity, touches the unrelated canary, or the focused gate flakes without an identified product or harness defect." }, + { + "id": "runtime.linux-cli-launch-contract", + "title": "Packaged Linux CLI commands run without FUSE, user namespaces, or a display", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "appimage-cli-entrypoint", + "surfaces": [ + "packaged Linux AppImage", + "bundled CLI launcher", + "extracted direct binary", + "desktop launch diagnosis" + ], + "platforms": ["linux"], + "providers": ["local-daemon"], + "coveredPlatforms": ["linux"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "A restricted Ubuntu container stages the extracted AppImage payload with no /dev/fuse and with unprivileged user namespaces denied, then runs eight CLI cases across the bundled launcher and the extracted direct binary. x64 only, because PR CI builds only --x64.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/13719", + "https://github.com/stablyai/orca/issues/14229" + ], + "invariant": "On a host without FUSE and without unprivileged user namespaces, every packaged CLI entrypoint either completes its command or reports a diagnosis, and never terminates on a signal.", + "oracle": "Build the image, stage the AppImage payload, and run each case in the restricted container. Assert the preconditions first: unshare -Ur must fail and /dev/fuse must be absent, so a relaxed runner fails the job rather than silently skipping. For each case require the exact expected exit status and an expected substring of the command's own output, with the harness RESULT/CRASHED/PRECONDITION_FAILED control lines excluded so a case name can never satisfy its own assertion. Any status of 128 or above is a crash and fails immediately. The per-case timeout is a failure deadline, never a success condition.", + "commands": [ + "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", + "shellcheck config/docker/cli-launch-contract/run-cli-case.sh" + ], + "testFiles": [ + "config/scripts/run-linux-cli-launch-contract-docker.mjs", + "config/docker/cli-launch-contract/run-cli-case.sh" + ], + "assertionRefs": [ + { + "file": "config/scripts/run-linux-cli-launch-contract-docker.mjs", + "assertions": [ + "the bundled launcher serves --help, --version, status, skills --help, and worktree list without Chromium", + "a direct binary launch reaching JavaScript runs the command instead of booting a GUI", + "a desktop launch with no display reports the missing-display diagnosis instead of trapping", + "a stale DISPLAY is diagnosed rather than trusted", + "expected output is matched against the command's own output, not the harness control lines" + ] + }, + { + "file": "config/docker/cli-launch-contract/run-cli-case.sh", + "assertions": [ + "the container refuses to run unless unprivileged user namespaces are denied and /dev/fuse is absent", + "an exit status of 128 or above is reported as a crash rather than compared to the expected status" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-31", + "runner": "ci", + "platform": "linux", + "command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", + "result": "passed", + "durationSeconds": 40, + "summary": "PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 ran all eight cases to ok on ubuntu-latest. The unshare and /dev/fuse preconditions held under moby's default seccomp profile rather than tripping." + }, + { + "date": "2026-09-01", + "runner": "local", + "platform": "linux", + "command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage", + "result": "passed", + "durationSeconds": 60, + "summary": "All eight cases passed on Ubuntu 24.04 amd64 hardware against an AppImage built from the stack tip, invoked against a copy of that artifact outside dist/." + } + ], + "runtimeBudget": { + "p95Seconds": 300, + "scope": "eight CLI launch cases in one restricted Ubuntu container" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "The harness is new; soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The same harness run against a stock release AppImage failed four of eight cases: nofuse-userns-bundled-version at status 93, and all three direct-binary cases crashed at status 133 (SIGTRAP, the uv_close abort of #13719 and #14229). The stack-tip AppImage passed all eight. Corroborated at artifact level: the stack-tip runtime is a static-pie ELF with no PT_INTERP, the stock runtime is dynamically linked. Caveat: the two skills cases previously asserted a substring that the harness's own RESULT line contained, so they passed independently of command output; both now assert the rendered help header, and the green runs above predate that change." + }, + "performanceBudget": { + "required": false, + "evidence": "The gate is CI-only and adds no product code path." + }, + "promotionCriteria": [ + "Collect 30 consecutive CI passes or 14 days without an unexplained flake.", + "Extend the matrix to arm64 once PR CI builds that architecture.", + "Re-run red/green against a stock AppImage after any change to the launcher entrypoint." + ], + "knownGaps": [ + "The preconditions depend on moby's default seccomp profile denying unshare(CLONE_NEWUSER) and on /dev/fuse being absent. A runner with a relaxed profile or a mounted /dev/fuse trips PRECONDITION_FAILED and fails the job rather than skipping.", + "x64 only: PR CI builds only --x64, so the arm64 launcher path is unexercised.", + "The harness covers CLI entrypoints only; it does not exercise a full desktop session." + ], + "demotionRule": "Keep experimental or demote if any case terminates on a signal, the preconditions stop holding on the CI runner, or an assertion can be satisfied by anything other than the command's own output." + }, { "id": "ssh-managed-hooks.node18-runtime-compatibility", "title": "SSH managed-hook companions load and install hooks on Node 18", diff --git a/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc b/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc new file mode 100644 index 00000000000..7b4b9e1f990 --- /dev/null +++ b/config/scripts/__fixtures__/node-pty-1.1.0-unix-pty.cc @@ -0,0 +1,799 @@ +/** + * Copyright (c) 2012-2015, Christopher Jeffrey (MIT License) + * Copyright (c) 2017, Daniel Imms (MIT License) + * + * pty.cc: + * This file is responsible for starting processes + * with pseudo-terminal file descriptors. + * + * See: + * man pty + * man tty_ioctl + * man termios + * man forkpty + */ + +/** + * Includes + */ + +#define NODE_ADDON_API_DISABLE_DEPRECATED +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/* forkpty */ +/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */ +#if defined(__linux__) +#include +#elif defined(__APPLE__) +#include +#elif defined(__FreeBSD__) +#include +#include +#elif defined(__OpenBSD__) +#include +#include +#endif + +/* Some platforms name VWERASE and VDISCARD differently */ +#if !defined(VWERASE) && defined(VWERSE) +#define VWERASE VWERSE +#endif +#if !defined(VDISCARD) && defined(VDISCRD) +#define VDISCARD VDISCRD +#endif + +/* for pty_getproc */ +#if defined(__linux__) +#include +#include +#elif defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#include +#endif + +/* NSIG - macro for highest signal + 1, should be defined */ +#ifndef NSIG +#define NSIG 32 +#endif + +/* macOS 10.14 back does not define this constant */ +#ifndef POSIX_SPAWN_SETSID + #define POSIX_SPAWN_SETSID 1024 +#endif + +/* environ for execvpe */ +/* node/src/node_child_process.cc */ +#if !defined(__APPLE__) +extern char **environ; +#endif + +#if defined(__APPLE__) +extern "C" { +// Changes the current thread's directory to a path or directory file +// descriptor. libpthread only exposes a syscall wrapper starting in +// macOS 10.12, but the system call dates back to macOS 10.5. On older OSes, +// the syscall is issued directly. +int pthread_chdir_np(const char* dir) API_AVAILABLE(macosx(10.12)); +int pthread_fchdir_np(int fd) API_AVAILABLE(macosx(10.12)); +} + +#define HANDLE_EINTR(x) ({ \ + int eintr_wrapper_counter = 0; \ + decltype(x) eintr_wrapper_result; \ + do { \ + eintr_wrapper_result = (x); \ + } while (eintr_wrapper_result == -1 && errno == EINTR && \ + eintr_wrapper_counter++ < 100); \ + eintr_wrapper_result; \ +}) +#endif + +struct ExitEvent { + int exit_code = 0, signal_code = 0; +}; + +void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) { + std::thread *th = new std::thread; + // Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE. + auto tsfn = Napi::ThreadSafeFunction::New( + env, + cb, // JavaScript function called asynchronously + "SetupExitCallback_resource", // Name + 0, // Unlimited queue + 1, // Only one thread will use this initially + [th](Napi::Env) { // Finalizer used to clean threads up + th->join(); + delete th; + }); + *th = std::thread([tsfn = std::move(tsfn), pid] { + auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) { + cb.Call({Napi::Number::New(env, exit_event->exit_code), + Napi::Number::New(env, exit_event->signal_code)}); + delete exit_event; + }; + + int ret; + int stat_loc; +#if defined(__APPLE__) + // Based on + // https://source.chromium.org/chromium/chromium/src/+/main:base/process/kill_mac.cc;l=35-69? + int kq = HANDLE_EINTR(kqueue()); + struct kevent change = {0}; + EV_SET(&change, pid, EVFILT_PROC, EV_ADD, NOTE_EXIT, 0, NULL); + ret = HANDLE_EINTR(kevent(kq, &change, 1, NULL, 0, NULL)); + if (ret == -1) { + if (errno == ESRCH) { + // At this point, one of the following has occurred: + // 1. The process has died but has not yet been reaped. + // 2. The process has died and has already been reaped. + // 3. The process is in the process of dying. It's no longer + // kqueueable, but it may not be waitable yet either. Mark calls + // this case the "zombie death race". + ret = HANDLE_EINTR(waitpid(pid, &stat_loc, WNOHANG)); + if (ret == 0) { + ret = kill(pid, SIGKILL); + if (ret != -1) { + HANDLE_EINTR(waitpid(pid, &stat_loc, 0)); + } + } + } + } else { + struct kevent event = {0}; + ret = HANDLE_EINTR(kevent(kq, NULL, 0, &event, 1, NULL)); + if (ret == 1) { + if ((event.fflags & NOTE_EXIT) && + (event.ident == static_cast(pid))) { + // The process is dead or dying. This won't block for long, if at + // all. + HANDLE_EINTR(waitpid(pid, &stat_loc, 0)); + } + } + } +#else + while (true) { + errno = 0; + if ((ret = waitpid(pid, &stat_loc, 0)) != pid) { + if (ret == -1 && errno == EINTR) { + continue; + } + if (ret == -1 && errno == ECHILD) { + // XXX node v0.8.x seems to have this problem. + // waitpid is already handled elsewhere. + ; + } else { + assert(false); + } + } + break; + } +#endif + ExitEvent *exit_event = new ExitEvent; + if (WIFEXITED(stat_loc)) { + exit_event->exit_code = WEXITSTATUS(stat_loc); // errno? + } + if (WIFSIGNALED(stat_loc)) { + exit_event->signal_code = WTERMSIG(stat_loc); + } + auto status = tsfn.BlockingCall(exit_event, callback); // In main thread + switch (status) { + case napi_closing: + break; + + case napi_queue_full: + Napi::Error::Fatal("SetupExitCallback", "Queue was full"); + + case napi_ok: + if (tsfn.Release() != napi_ok) { + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed"); + } + break; + + default: + Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed"); + } + }); +} + +/** + * Methods + */ + +Napi::Value PtyFork(const Napi::CallbackInfo& info); +Napi::Value PtyOpen(const Napi::CallbackInfo& info); +Napi::Value PtyResize(const Napi::CallbackInfo& info); +Napi::Value PtyGetProc(const Napi::CallbackInfo& info); + +/** + * Functions + */ + +static int +pty_nonblock(int); + +#if defined(__APPLE__) +static char * +pty_getproc(int); +#else +static char * +pty_getproc(int, char *); +#endif + +#if defined(__APPLE__) || defined(__OpenBSD__) +static void +pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, + int* err); +#endif + +struct DelBuf { + int len; + DelBuf(int len) : len(len) {} + void operator()(char **p) { + if (p == nullptr) + return; + for (int i = 0; i < len; i++) + free(p[i]); + delete[] p; + } +}; + +Napi::Value PtyFork(const Napi::CallbackInfo& info) { + Napi::Env napiEnv(info.Env()); + Napi::HandleScope scope(napiEnv); + + if (info.Length() != 11 || + !info[0].IsString() || + !info[1].IsArray() || + !info[2].IsArray() || + !info[3].IsString() || + !info[4].IsNumber() || + !info[5].IsNumber() || + !info[6].IsNumber() || + !info[7].IsNumber() || + !info[8].IsBoolean() || + !info[9].IsString() || + !info[10].IsFunction()) { + throw Napi::Error::New(napiEnv, "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, helperPath, onexit)"); + } + + // file + std::string file = info[0].As(); + + // args + Napi::Array argv_ = info[1].As(); + + // env + Napi::Array env_ = info[2].As(); + int envc = env_.Length(); + std::unique_ptr env_unique_ptr(new char *[envc + 1], DelBuf(envc + 1)); + char **env = env_unique_ptr.get(); + env[envc] = NULL; + for (int i = 0; i < envc; i++) { + std::string pair = env_.Get(i).As(); + env[i] = strdup(pair.c_str()); + } + + // cwd + std::string cwd_ = info[3].As(); + + // size + struct winsize winp; + winp.ws_col = info[4].As().Int32Value(); + winp.ws_row = info[5].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + +#if !defined(__APPLE__) + // uid / gid + int uid = info[6].As().Int32Value(); + int gid = info[7].As().Int32Value(); +#endif + + // termios + struct termios t = termios(); + struct termios *term = &t; + term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT; + if (info[8].As().Value()) { +#if defined(IUTF8) + term->c_iflag |= IUTF8; +#endif + } + term->c_oflag = OPOST | ONLCR; + term->c_cflag = CREAD | CS8 | HUPCL; + term->c_lflag = ICANON | ISIG | IEXTEN | ECHO | ECHOE | ECHOK | ECHOKE | ECHOCTL; + + term->c_cc[VEOF] = 4; + term->c_cc[VEOL] = -1; + term->c_cc[VEOL2] = -1; + term->c_cc[VERASE] = 0x7f; + term->c_cc[VWERASE] = 23; + term->c_cc[VKILL] = 21; + term->c_cc[VREPRINT] = 18; + term->c_cc[VINTR] = 3; + term->c_cc[VQUIT] = 0x1c; + term->c_cc[VSUSP] = 26; + term->c_cc[VSTART] = 17; + term->c_cc[VSTOP] = 19; + term->c_cc[VLNEXT] = 22; + term->c_cc[VDISCARD] = 15; + term->c_cc[VMIN] = 1; + term->c_cc[VTIME] = 0; + + #if (__APPLE__) + term->c_cc[VDSUSP] = 25; + term->c_cc[VSTATUS] = 20; + #endif + + cfsetispeed(term, B38400); + cfsetospeed(term, B38400); + + // helperPath + std::string helper_path = info[9].As(); + + pid_t pid; + int master; +#if defined(__APPLE__) + int argc = argv_.Length(); + int argl = argc + 4; + std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl)); + char **argv = argv_unique_ptr.get(); + argv[0] = strdup(helper_path.c_str()); + argv[1] = strdup(cwd_.c_str()); + argv[2] = strdup(file.c_str()); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + std::string arg = argv_.Get(i).As(); + argv[i + 3] = strdup(arg.c_str()); + } + + int err = -1; + pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); + if (err != 0) { + throw Napi::Error::New(napiEnv, "posix_spawnp failed."); + } + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } +#else + int argc = argv_.Length(); + int argl = argc + 2; + std::unique_ptr argv_unique_ptr(new char *[argl], DelBuf(argl)); + char** argv = argv_unique_ptr.get(); + argv[0] = strdup(file.c_str()); + argv[argl - 1] = NULL; + for (int i = 0; i < argc; i++) { + std::string arg = argv_.Get(i).As(); + argv[i + 1] = strdup(arg.c_str()); + } + + sigset_t newmask, oldmask; + struct sigaction sig_action; + // temporarily block all signals + // this is needed due to a race condition in openpty + // and to avoid running signal handlers in the child + // before exec* happened + sigfillset(&newmask); + pthread_sigmask(SIG_SETMASK, &newmask, &oldmask); + + pid = forkpty(&master, nullptr, static_cast(term), static_cast(&winp)); + + if (!pid) { + // remove all signal handler from child + sig_action.sa_handler = SIG_DFL; + sig_action.sa_flags = 0; + sigemptyset(&sig_action.sa_mask); + for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1 + sigaction(i, &sig_action, NULL); + } + } + + // reenable signals + pthread_sigmask(SIG_SETMASK, &oldmask, NULL); + + switch (pid) { + case -1: + throw Napi::Error::New(napiEnv, "forkpty(3) failed."); + case 0: + if (strlen(cwd_.c_str())) { + if (chdir(cwd_.c_str()) == -1) { + perror("chdir(2) failed."); + _exit(1); + } + } + + if (uid != -1 && gid != -1) { + if (setgid(gid) == -1) { + perror("setgid(2) failed."); + _exit(1); + } + if (setuid(uid) == -1) { + perror("setuid(2) failed."); + _exit(1); + } + } + + { + char **old = environ; + environ = env; + execvp(argv[0], argv); + environ = old; + perror("execvp(3) failed."); + _exit(1); + } + default: + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + } +#endif + + Napi::Object obj = Napi::Object::New(napiEnv); + obj.Set("fd", Napi::Number::New(napiEnv, master)); + obj.Set("pid", Napi::Number::New(napiEnv, pid)); + obj.Set("pty", Napi::String::New(napiEnv, ptsname(master))); + + // Set up process exit callback. + Napi::Function cb = info[10].As(); + SetupExitCallback(napiEnv, cb, pid); + return obj; +} + +Napi::Value PtyOpen(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.open(cols, rows)"); + } + + // size + struct winsize winp; + winp.ws_col = info[0].As().Int32Value(); + winp.ws_row = info[1].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + // pty + int master, slave; + int ret = openpty(&master, &slave, nullptr, NULL, static_cast(&winp)); + + if (ret == -1) { + throw Napi::Error::New(env, "openpty(3) failed."); + } + + if (pty_nonblock(master) == -1) { + throw Napi::Error::New(env, "Could not set master fd to nonblocking."); + } + + if (pty_nonblock(slave) == -1) { + throw Napi::Error::New(env, "Could not set slave fd to nonblocking."); + } + + Napi::Object obj = Napi::Object::New(env); + obj.Set("master", Napi::Number::New(env, master)); + obj.Set("slave", Napi::Number::New(env, slave)); + obj.Set("pty", Napi::String::New(env, ptsname(master))); + + return obj; +} + +Napi::Value PtyResize(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + + if (info.Length() != 3 || + !info[0].IsNumber() || + !info[1].IsNumber() || + !info[2].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.resize(fd, cols, rows)"); + } + + int fd = info[0].As().Int32Value(); + + struct winsize winp; + winp.ws_col = info[1].As().Int32Value(); + winp.ws_row = info[2].As().Int32Value(); + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + if (ioctl(fd, TIOCSWINSZ, &winp) == -1) { + switch (errno) { + case EBADF: + throw Napi::Error::New(env, "ioctl(2) failed, EBADF"); + case EFAULT: + throw Napi::Error::New(env, "ioctl(2) failed, EFAULT"); + case EINVAL: + throw Napi::Error::New(env, "ioctl(2) failed, EINVAL"); + case ENOTTY: + throw Napi::Error::New(env, "ioctl(2) failed, ENOTTY"); + } + throw Napi::Error::New(env, "ioctl(2) failed"); + } + + return env.Undefined(); +} + +/** + * Foreground Process Name + */ +Napi::Value PtyGetProc(const Napi::CallbackInfo& info) { + Napi::Env env(info.Env()); + Napi::HandleScope scope(env); + +#if defined(__APPLE__) + if (info.Length() != 1 || + !info[0].IsNumber()) { + throw Napi::Error::New(env, "Usage: pty.process(pid)"); + } + + int fd = info[0].As().Int32Value(); + char *name = pty_getproc(fd); +#else + if (info.Length() != 2 || + !info[0].IsNumber() || + !info[1].IsString()) { + throw Napi::Error::New(env, "Usage: pty.process(fd, tty)"); + } + + int fd = info[0].As().Int32Value(); + + std::string tty_ = info[1].As(); + char *tty = strdup(tty_.c_str()); + char *name = pty_getproc(fd, tty); + free(tty); +#endif + + if (name == NULL) { + return env.Undefined(); + } + + Napi::String name_ = Napi::String::New(env, name); + free(name); + return name_; +} + +/** + * Nonblocking FD + */ + +static int +pty_nonblock(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags == -1) return -1; + return fcntl(fd, F_SETFL, flags | O_NONBLOCK); +} + +/** + * pty_getproc + * Taken from tmux. + */ + +// Taken from: tmux (http://tmux.sourceforge.net/) +// Copyright (c) 2009 Nicholas Marriott +// Copyright (c) 2009 Joshua Elsasser +// Copyright (c) 2009 Todd Carson +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER +// IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +// OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#if defined(__linux__) + +static char * +pty_getproc(int fd, char *tty) { + FILE *f; + char *path, *buf; + size_t len; + int ch; + pid_t pgrp; + int r; + + if ((pgrp = tcgetpgrp(fd)) == -1) { + return NULL; + } + + r = asprintf(&path, "/proc/%lld/cmdline", (long long)pgrp); + if (r == -1 || path == NULL) return NULL; + + if ((f = fopen(path, "r")) == NULL) { + free(path); + return NULL; + } + + free(path); + + len = 0; + buf = NULL; + while ((ch = fgetc(f)) != EOF) { + if (ch == '\0') break; + buf = (char *)realloc(buf, len + 2); + if (buf == NULL) return NULL; + buf[len++] = ch; + } + + if (buf != NULL) { + buf[len] = '\0'; + } + + fclose(f); + return buf; +} + +#elif defined(__APPLE__) + +static char * +pty_getproc(int fd) { + int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, 0 }; + size_t size; + struct kinfo_proc kp; + + if ((mib[3] = tcgetpgrp(fd)) == -1) { + return NULL; + } + + size = sizeof kp; + if (sysctl(mib, 4, &kp, &size, NULL, 0) == -1) { + return NULL; + } + + if (size != (sizeof kp) || *kp.kp_proc.p_comm == '\0') { + return NULL; + } + + return strdup(kp.kp_proc.p_comm); +} + +#else + +static char * +pty_getproc(int fd, char *tty) { + return NULL; +} + +#endif + +#if defined(__APPLE__) +static void +pty_posix_spawn(char** argv, char** env, + const struct termios *termp, + const struct winsize *winp, + int* master, + pid_t* pid, + int* err) { + int low_fds[3]; + size_t count = 0; + + for (; count < 3; count++) { + low_fds[count] = posix_openpt(O_RDWR); + if (low_fds[count] >= STDERR_FILENO) + break; + } + + int flags = POSIX_SPAWN_CLOEXEC_DEFAULT | + POSIX_SPAWN_SETSIGDEF | + POSIX_SPAWN_SETSIGMASK | + POSIX_SPAWN_SETSID; + *master = posix_openpt(O_RDWR); + if (*master == -1) { + return; + } + + int res = grantpt(*master) || unlockpt(*master); + if (res == -1) { + return; + } + + // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. + int slave; + char slave_pty_name[128]; + res = ioctl(*master, TIOCPTYGNAME, slave_pty_name); + if (res == -1) { + return; + } + + slave = open(slave_pty_name, O_RDWR | O_NOCTTY); + if (slave == -1) { + return; + } + + if (termp) { + res = tcsetattr(slave, TCSANOW, termp); + if (res == -1) { + return; + }; + } + + if (winp) { + res = ioctl(slave, TIOCSWINSZ, winp); + if (res == -1) { + return; + } + } + + posix_spawn_file_actions_t acts; + posix_spawn_file_actions_init(&acts); + posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); + posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); + posix_spawn_file_actions_addclose(&acts, slave); + posix_spawn_file_actions_addclose(&acts, *master); + + posix_spawnattr_t attrs; + posix_spawnattr_init(&attrs); + *err = posix_spawnattr_setflags(&attrs, flags); + if (*err != 0) { + goto done; + } + + sigset_t signal_set; + /* Reset all signal the child to their default behavior */ + sigfillset(&signal_set); + *err = posix_spawnattr_setsigdefault(&attrs, &signal_set); + if (*err != 0) { + goto done; + } + + /* Reset the signal mask for all signals */ + sigemptyset(&signal_set); + *err = posix_spawnattr_setsigmask(&attrs, &signal_set); + if (*err != 0) { + goto done; + } + + do + *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); + while (*err == EINTR); +done: + posix_spawn_file_actions_destroy(&acts); + posix_spawnattr_destroy(&attrs); + + for (; count > 0; count--) { + close(low_fds[count]); + } +} +#endif + +/** + * Init + */ + +Napi::Object init(Napi::Env env, Napi::Object exports) { + exports.Set("fork", Napi::Function::New(env, PtyFork)); + exports.Set("open", Napi::Function::New(env, PtyOpen)); + exports.Set("resize", Napi::Function::New(env, PtyResize)); + exports.Set("process", Napi::Function::New(env, PtyGetProc)); + return exports; +} + +NODE_API_MODULE(NODE_GYP_MODULE_NAME, init) diff --git a/config/scripts/build-linux-local.mjs b/config/scripts/build-linux-local.mjs new file mode 100644 index 00000000000..2328f00bede --- /dev/null +++ b/config/scripts/build-linux-local.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const SUPPORTED_ARCHES = new Set(['x64', 'arm64']) + +/** Select the local Linux package architecture without relying on builder defaults. */ +export function resolveLinuxBuildArch({ + platform = process.platform, + hostArch = process.arch, + requestedArch = process.env.ORCA_LINUX_BUILD_ARCH +} = {}) { + const arch = requestedArch ?? (platform === 'linux' ? hostArch : 'x64') + if (!SUPPORTED_ARCHES.has(arch)) { + throw new Error( + `Unsupported Linux build architecture: ${arch}. Use ORCA_LINUX_BUILD_ARCH=x64|arm64.` + ) + } + return arch +} + +export function buildLinuxElectronBuilderArgs(arch, extraArgs = []) { + if (!SUPPORTED_ARCHES.has(arch)) { + throw new Error(`Unsupported Linux build architecture: ${arch}`) + } + return [ + 'exec', + 'electron-builder', + '--config', + 'config/electron-builder.config.cjs', + '--linux', + 'AppImage', + 'deb', + 'rpm', + `--${arch}`, + ...extraArgs + ] +} + +export function runLocalLinuxBuild({ + arch = resolveLinuxBuildArch(), + extraArgs = [], + environment = process.env, + execFile = execFileSync, + platform = process.platform, + cwd = resolve(import.meta.dirname, '../..') +} = {}) { + const env = { ...environment } + if (arch === 'arm64') { + env.ORCA_LINUX_ARM64_RELEASE = '1' + } else { + delete env.ORCA_LINUX_ARM64_RELEASE + } + const pnpm = platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + execFile(pnpm, buildLinuxElectronBuilderArgs(arch, extraArgs), { + cwd, + env, + stdio: 'inherit' + }) +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + runLocalLinuxBuild({ extraArgs: process.argv.slice(2) }) +} diff --git a/config/scripts/build-linux-local.test.mjs b/config/scripts/build-linux-local.test.mjs new file mode 100644 index 00000000000..684c83f3265 --- /dev/null +++ b/config/scripts/build-linux-local.test.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + buildLinuxElectronBuilderArgs, + resolveLinuxBuildArch, + runLocalLinuxBuild +} from './build-linux-local.mjs' + +describe('local Linux build target', () => { + it('is the package script used by the local Linux build', () => { + const packageJson = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8') + ) + expect(packageJson.scripts['build:linux']).toContain( + 'node config/scripts/build-linux-local.mjs' + ) + }) + + it('follows a native Linux host architecture', () => { + expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'arm64' })).toBe('arm64') + expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'x64' })).toBe('x64') + }) + + it('defaults cross-platform Linux builds to x64 and allows an explicit override', () => { + expect(resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64' })).toBe('x64') + expect( + resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64', requestedArch: 'arm64' }) + ).toBe('arm64') + }) + + it('rejects unsupported architectures', () => { + expect(() => resolveLinuxBuildArch({ platform: 'linux', hostArch: 'ia32' })).toThrow( + 'Unsupported Linux build architecture' + ) + expect(() => buildLinuxElectronBuilderArgs('ia32')).toThrow( + 'Unsupported Linux build architecture' + ) + }) + + it('passes an explicit target and matching artifact-name environment', () => { + const execFile = vi.fn() + runLocalLinuxBuild({ + arch: 'arm64', + environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: undefined }, + execFile, + platform: 'linux', + cwd: '/workspace' + }) + expect(execFile).toHaveBeenCalledWith( + 'pnpm', + buildLinuxElectronBuilderArgs('arm64'), + expect.objectContaining({ + cwd: '/workspace', + env: expect.objectContaining({ ORCA_LINUX_ARM64_RELEASE: '1' }), + stdio: 'inherit' + }) + ) + + expect(buildLinuxElectronBuilderArgs('x64')).toEqual( + expect.arrayContaining(['--linux', 'AppImage', 'deb', 'rpm', '--x64']) + ) + + runLocalLinuxBuild({ + arch: 'x64', + environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: '1' }, + execFile, + platform: 'linux', + cwd: '/workspace' + }) + expect(execFile).toHaveBeenLastCalledWith( + 'pnpm', + buildLinuxElectronBuilderArgs('x64'), + expect.objectContaining({ + env: expect.not.objectContaining({ ORCA_LINUX_ARM64_RELEASE: expect.anything() }) + }) + ) + }) + + it('uses the Windows pnpm command name when cross-host packaging', () => { + const execFile = vi.fn() + runLocalLinuxBuild({ arch: 'x64', execFile, platform: 'win32', cwd: 'C:\\workspace' }) + expect(execFile.mock.calls[0]?.[0]).toBe('pnpm.cmd') + }) +}) diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs index efbafbe9a1b..2d818d5d255 100644 --- a/config/scripts/build-orcad-prebuilds.mjs +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -177,10 +177,11 @@ function build() { copyFileSync(builtBinary, join(slotDir, 'pty.node')) console.log(`[orcad-prebuilds] stored ${slot}/pty.node`) - // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper, + // Why spawn-helper ships too: on macOS node-pty posix_spawns build/Release/spawn-helper, // so a slot without it installs cleanly and then fails ENOENT the first time a user - // opens a terminal. Windows has no spawn-helper. - if (process.platform !== 'win32') { + // opens a terminal. binding.gyp builds the helper only under OS=="mac"; every other + // platform forks directly, so demanding one there fails a healthy Linux slot build. + if (process.platform === 'darwin') { const helperSource = join(dirname(builtBinary), 'spawn-helper') if (!existsSync(helperSource)) { throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`) diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 506036ede3a..289c7a957bd 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -57,6 +57,13 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join( 'relay-assets', NODE_PTY_CONSOLE_LIST_PATCH_FILENAME ) +const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' +const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join( + ROOT, + 'config', + 'relay-assets', + NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME +) // Written by build-windows-process-tree-relay-addon.mjs, which only runs on a // Windows machine. const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree') @@ -126,6 +133,10 @@ for (const platform of RELAY_BUILD_PLATFORMS) { join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME) ) } + copyFileSync( + NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE, + join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME) + ) stageWindowsProcessTreeAddon(platform, outDir) await build({ diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 66d2549ea4d..4427c6b7f38 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -4,6 +4,7 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ export const OXLINT_SCANS = [ @@ -285,11 +286,12 @@ function isSuppressedDiagnostic(diagnostic, root) { } function runOxlintScan(root, scan, files) { - const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' - const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], { + const { command, prefixArgs } = resolveOxlintInvocation(root) + const result = spawnSync(command, [...prefixArgs, ...scan.args, '--format', 'json', ...files], { cwd: root, encoding: 'utf8', - maxBuffer: 128 * 1024 * 1024 + maxBuffer: 128 * 1024 * 1024, + windowsHide: true }) if (result.error) { throw result.error diff --git a/config/scripts/check-react-doctor-changed.mjs b/config/scripts/check-react-doctor-changed.mjs index f659eefb3d4..743a64eee04 100644 --- a/config/scripts/check-react-doctor-changed.mjs +++ b/config/scripts/check-react-doctor-changed.mjs @@ -1,16 +1,30 @@ import { spawnSync } from 'node:child_process' import process from 'node:process' import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' const requestedBase = process.argv.slice(2).find((argument) => argument !== '--') ?? process.env.ORCA_CODE_QUALITY_BASE ?? 'origin/main' const base = resolvePullRequestDiffBase(process.cwd(), requestedBase) -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +// Why validate rather than trust: `base` arrives from argv or the environment and +// below it can reach cmd.exe unquoted, because resolvePnpmCliInvocation still +// falls back to a shell when it cannot find a directly spawnable pnpm. It accepts +// SHAs, tags, ref paths and the ^ ~ .. suffixes -- not reflog syntax like HEAD@{1}, +// because braces stay out of anything bound for cmd.exe. The error names the base. +const GIT_REVISION = /^[A-Za-z0-9._/@^~-]+$/ +if (!GIT_REVISION.test(base)) { + throw new Error(`Refusing to pass an unsafe diff base to pnpm: ${base}`) +} +// Why the shim and not a direct binary: `dlx` fetches react-doctor on demand, so +// only the pnpm CLI can run it. resolvePnpmCliInvocation prefers whatever +// npm_execpath exposes -- pnpm 12's own pnpm.exe, spawned with no shell. +const { command, prefixArgs, shell } = resolvePnpmCliInvocation() const result = spawnSync( - pnpm, + command, [ + ...prefixArgs, 'dlx', 'react-doctor@0.9.1', '.', @@ -26,7 +40,7 @@ const result = spawnSync( '--blocking', 'error' ], - { stdio: 'inherit' } + { stdio: 'inherit', shell, windowsHide: true } ) if (result.error) { diff --git a/config/scripts/check-react-doctor-changed.test.mjs b/config/scripts/check-react-doctor-changed.test.mjs new file mode 100644 index 00000000000..2c5feb90a5d --- /dev/null +++ b/config/scripts/check-react-doctor-changed.test.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' +import { describe, expect, it } from 'vitest' + +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const script = path.join(repoRoot, 'config', 'scripts', 'check-react-doctor-changed.mjs') + +function runWithBase(base) { + return spawnSync(process.execPath, [script, base], { + cwd: repoRoot, + encoding: 'utf8', + windowsHide: true + }) +} + +describe('check-react-doctor-changed diff base', () => { + // The pnpm invocation can still fall back to a shell, so an unvalidated base + // would reach cmd.exe unquoted. Rejection has to happen before the spawn. + it.each(['main & calc', 'main | whoami', 'main"x', '%PATH%', 'main $(id)'])( + 'refuses %j', + (base) => { + const result = runWithBase(base) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Refusing to pass an unsafe diff base') + } + ) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 347cae8fcfc..3f055ce222d 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,4 +1,4 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,17 +10,8 @@ const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main') const require = createRequire(import.meta.url) const electronBuilderConfig = require('../electron-builder.config.cjs') const { FileMatcher } = require('app-builder-lib/out/fileMatcher') +const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs') -const { - createPackagedRuntimeNodeModuleResources, - findAsarEntry, - prunePackagedNodePty, - prunePackagedParcelWatcher, - prunePackagedSherpaOnnx, - prunePackagedRuntimeTypeAndSourceMapArtifacts, - prunePackagedZodSources, - verifyPackagedMainRuntimeDeps -} = require('../packaged-runtime-node-modules.cjs') describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { @@ -280,8 +271,9 @@ describe('electron-builder config', () => { expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca') }) - it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => { - expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb']) + it('uses the release artifact set as local Linux targets without changing existing names', () => { + expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm']) + expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' }) expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}') expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}') expect(electronBuilderConfig.rpm).toMatchObject({ @@ -290,6 +282,33 @@ describe('electron-builder config', () => { }) }) + it('retains electron-builder runtime dependencies in deb and rpm packages', () => { + for (const target of ['deb', 'rpm']) { + const dependencies = electronBuilderConfig[target].depends + expect(dependencies).toEqual( + expect.arrayContaining(FpmTarget.prototype.getDefaultDepends(target)) + ) + expect(new Set(dependencies).size).toBe(dependencies.length) + } + }) + + it('validates each AppImage before electron-builder publishes it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-')) + try { + const appImage = join(root, 'orca-linux.AppImage') + await writeFile(appImage, 'not an ELF') + await chmod(appImage, 0o755) + + expect(() => + electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 }) + ).toThrow(/ELF header is outside/) + expect(() => + electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') }) + ).not.toThrow() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) it('uses a distinct AppImage name for Linux arm64 release uploads', () => { const configPath = require.resolve('../electron-builder.config.cjs') const original = process.env.ORCA_LINUX_ARM64_RELEASE @@ -367,286 +386,6 @@ describe('electron-builder config', () => { expect(electronBuilderConfig.npmRebuild).toBe(true) }) - it('verifies packaged main runtime deps from Windows-style asar entries', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) - try { - await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8') - await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true }) - await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true }) - - const sources = new Map([ - ['out\\main\\index.js', 'const z = require("zod")'], - ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")'] - ]) - const asar = { - listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`), - extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8') - } - - expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('normalizes host-specific asar entry separators', () => { - expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe( - '\\out\\main\\index.js' - ) - expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js') - }) - - it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-')) - try { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const prebuildsDir = join(nodePtyDir, 'prebuilds') - const binDir = join(nodePtyDir, 'bin') - await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true }) - await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true }) - await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true }) - await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true }) - await mkdir(join(nodePtyDir, 'third_party', 'conpty'), { - recursive: true - }) - await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true }) - - prunePackagedNodePty(resourcesDir, 'darwin', 3) - - await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64']) - await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148']) - await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([]) - await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([]) - expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow( - 'Unsupported packaged runtime architecture: 4' - ) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => { - for (const [arch, electronArch] of [ - ['x64', 1], - ['arm64', 3] - ]) { - const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`)) - try { - const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') - const releaseDir = join(nodePtyDir, 'build', 'Release') - const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0') - await mkdir(releaseDir, { recursive: true }) - await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8') - for (const sourceArch of ['x64', 'arm64']) { - const sourceDir = join(conptyRoot, `win10-${sourceArch}`) - await mkdir(sourceDir, { recursive: true }) - await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8') - await writeFile( - join(sourceDir, 'OpenConsole.exe'), - `console payload ${sourceArch}`, - 'utf8' - ) - } - - prunePackagedNodePty(resourcesDir, 'win32', electronArch) - - await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe( - `dll payload ${arch}` - ) - await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe( - `console payload ${arch}` - ) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - } - }) - - it('includes external main dependencies in the packaged runtime closure', () => { - // Why: the main process imports '@parcel/watcher' for filesystem change - // events; if it is absent from the packaged closure the serve host silently - // stops propagating file changes to clients (regression guard for #4851). - const packaged = createPackagedRuntimeNodeModuleResources() - const packagedTargets = packaged.map((resource) => resource.to) - expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher')) - expect( - packagedTargets.some((target) => - target.startsWith(join('node_modules', '@parcel', 'watcher-')) - ) - ).toBe(true) - expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile')) - }) - - it('prunes non-target @parcel/watcher architecture subpackages', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-')) - try { - const parcelDir = join(resourcesDir, 'node_modules', '@parcel') - await mkdir(join(parcelDir, 'watcher'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true }) - - prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64') - - await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ - 'watcher', - 'watcher-linux-arm64-glibc' - ]) - expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow( - 'Unsupported packaged runtime architecture: universal' - ) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-')) - try { - const parcelDir = join(resourcesDir, 'node_modules', '@parcel') - await mkdir(join(parcelDir, 'watcher'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) - await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) - // A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage. - await mkdir(join(parcelDir, 'transformer-js'), { recursive: true }) - - prunePackagedParcelWatcher(resourcesDir, 'linux', 1) - - await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ - 'transformer-js', - 'watcher', - 'watcher-linux-x64-glibc' - ]) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('prunes type declaration artifacts from packaged runtime node_modules', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-')) - try { - const packageDir = join(resourcesDir, 'node_modules', 'example-package') - await mkdir(join(packageDir, 'dist'), { recursive: true }) - await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.mts'), 'export type Value = string', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.cts.map'), '{}', 'utf8') - await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8') - - prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir) - - await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-')) - try { - const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64') - await mkdir(packageDir, { recursive: true }) - await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8') - await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8') - await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8') - - prunePackagedSherpaOnnx(resourcesDir, 'darwin') - - await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([ - 'libonnxruntime.1.23.2.dylib', - 'sherpa-onnx.node' - ]) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('prunes zod TypeScript sources from packaged runtime resources', async () => { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-')) - try { - const packageDir = join(resourcesDir, 'node_modules', 'zod') - await mkdir(join(packageDir, 'src'), { recursive: true }) - await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8') - await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8') - - prunePackagedZodSources(resourcesDir) - - await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) - } finally { - await rm(resourcesDir, { recursive: true, force: true }) - } - }) - - it('fails when the packaged resources directory is missing', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) - try { - await expect( - electronBuilderConfig.afterPack({ - appOutDir: root, - electronPlatformName: 'win32' - }) - ).rejects.toThrow(/Missing packaged resources directory/) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) - - it.skipIf(process.platform === 'win32')( - 'marks packaged Unix CLI launchers executable', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) - try { - const resourcesDir = join(root, 'linux-unpacked', 'resources') - const launcherPath = join(resourcesDir, 'bin', 'orca-ide') - await mkdir(join(resourcesDir, 'bin'), { recursive: true }) - await cp( - join(process.cwd(), 'resources', 'plugins', 'launch'), - join(resourcesDir, 'plugins', 'launch'), - { recursive: true } - ) - await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) - // Why: afterPack now fails hard when the unpacked daemon entry is - // missing, so the fixture must carry one like a real package layout. - const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') - await mkdir(unpackedMainDir, { recursive: true }) - await writeFile( - join(unpackedMainDir, 'daemon-entry.js'), - 'console.error("Usage: daemon-entry "); process.exit(1)\n', - 'utf8' - ) - const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') - await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) - await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') - await writeFile( - join(unpackedCliDir, 'index.js'), - [ - 'const args = process.argv.slice(2)', - "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", - "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", - 'else console.log(JSON.stringify({ executed: false }))' - ].join('\n'), - 'utf8' - ) - await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) - - await electronBuilderConfig.afterPack({ - appOutDir: join(root, 'linux-unpacked'), - electronPlatformName: 'linux', - arch: 1 - }) - - expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) - } finally { - await rm(root, { recursive: true, force: true }) - } - } - ) - // Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that // app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds // one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit @@ -680,5 +419,17 @@ describe('electron-builder config', () => { expect(source).toContain(`value === '${target}'`) } }) + + it('keeps the pinned FpmTarget overwrite for configured deb and rpm artifacts', async () => { + const source = await readFile( + require.resolve('app-builder-lib/out/targets/FpmTarget'), + 'utf8' + ) + + expect(source).toContain('path.join(resourceDir, "package-type"), target') + for (const target of RECOVERABLE_TARGETS) { + expect(electronBuilderConfig[target]).toBeDefined() + } + }) }) }) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs new file mode 100644 index 00000000000..d2407776fa7 --- /dev/null +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -0,0 +1,308 @@ +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') +const { + createPackagedRuntimeNodeModuleResources, + findAsarEntry, + prunePackagedNodePty, + prunePackagedParcelWatcher, + prunePackagedSherpaOnnx, + prunePackagedRuntimeTypeAndSourceMapArtifacts, + prunePackagedZodSources, + verifyPackagedMainRuntimeDeps +} = require('../packaged-runtime-node-modules.cjs') + +describe('packaged runtime resources', () => { + it('verifies packaged main runtime deps from Windows-style asar entries', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-')) + try { + await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8') + await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true }) + await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true }) + + const sources = new Map([ + ['out\\main\\index.js', 'const z = require("zod")'], + ['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")'] + ]) + const asar = { + listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`), + extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8') + } + + expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow() + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('normalizes host-specific asar entry separators', () => { + expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe( + '\\out\\main\\index.js' + ) + expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js') + }) + + it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-')) + try { + const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') + const prebuildsDir = join(nodePtyDir, 'prebuilds') + const binDir = join(nodePtyDir, 'bin') + await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true }) + await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true }) + await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true }) + await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true }) + await mkdir(join(nodePtyDir, 'third_party', 'conpty'), { + recursive: true + }) + await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true }) + + prunePackagedNodePty(resourcesDir, 'darwin', 3) + + await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64']) + await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148']) + await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([]) + await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([]) + expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow( + 'Unsupported packaged runtime architecture: 4' + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => { + for (const [arch, electronArch] of [ + ['x64', 1], + ['arm64', 3] + ]) { + const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`)) + try { + const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty') + const releaseDir = join(nodePtyDir, 'build', 'Release') + const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0') + await mkdir(releaseDir, { recursive: true }) + await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8') + for (const sourceArch of ['x64', 'arm64']) { + const sourceDir = join(conptyRoot, `win10-${sourceArch}`) + await mkdir(sourceDir, { recursive: true }) + await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8') + await writeFile( + join(sourceDir, 'OpenConsole.exe'), + `console payload ${sourceArch}`, + 'utf8' + ) + } + + prunePackagedNodePty(resourcesDir, 'win32', electronArch) + + await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe( + `dll payload ${arch}` + ) + await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe( + `console payload ${arch}` + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + } + }) + + it('includes external main dependencies in the packaged runtime closure', () => { + // Why: the main process imports '@parcel/watcher' for filesystem change + // events; if it is absent from the packaged closure the serve host silently + // stops propagating file changes to clients (regression guard for #4851). + const packaged = createPackagedRuntimeNodeModuleResources() + const packagedTargets = packaged.map((resource) => resource.to) + expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher')) + expect( + packagedTargets.some((target) => + target.startsWith(join('node_modules', '@parcel', 'watcher-')) + ) + ).toBe(true) + expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile')) + }) + + it('prunes non-target @parcel/watcher architecture subpackages', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-')) + try { + const parcelDir = join(resourcesDir, 'node_modules', '@parcel') + await mkdir(join(parcelDir, 'watcher'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true }) + + prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64') + + await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ + 'watcher', + 'watcher-linux-arm64-glibc' + ]) + expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow( + 'Unsupported packaged runtime architecture: universal' + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-')) + try { + const parcelDir = join(resourcesDir, 'node_modules', '@parcel') + await mkdir(join(parcelDir, 'watcher'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true }) + await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true }) + // A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage. + await mkdir(join(parcelDir, 'transformer-js'), { recursive: true }) + + prunePackagedParcelWatcher(resourcesDir, 'linux', 1) + + await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([ + 'transformer-js', + 'watcher', + 'watcher-linux-x64-glibc' + ]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes type declaration artifacts from packaged runtime node_modules', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'example-package') + await mkdir(join(packageDir, 'dist'), { recursive: true }) + await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8') + await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8') + + prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir) + + await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs']) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64') + await mkdir(packageDir, { recursive: true }) + await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8') + await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8') + await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8') + + prunePackagedSherpaOnnx(resourcesDir, 'darwin') + + await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([ + 'libonnxruntime.1.23.2.dylib', + 'sherpa-onnx.node' + ]) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('prunes zod TypeScript sources from packaged runtime resources', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-')) + try { + const packageDir = join(resourcesDir, 'node_modules', 'zod') + await mkdir(join(packageDir, 'src'), { recursive: true }) + await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8') + await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8') + + prunePackagedZodSources(resourcesDir) + + await expect(readdir(packageDir)).resolves.toEqual(['index.cjs']) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('fails when the packaged resources directory is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + await expect( + electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'win32' + }) + ).rejects.toThrow(/Missing packaged resources directory/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'marks packaged Unix CLI launchers executable', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + const resourcesDir = join(root, 'linux-unpacked', 'resources') + const launcherPath = join(resourcesDir, 'bin', 'orca-ide') + await mkdir(join(resourcesDir, 'bin'), { recursive: true }) + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) + await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + // Why: afterPack now fails hard when the unpacked daemon entry is + // missing, so the fixture must carry one like a real package layout. + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) + await writeFile( + join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), + `${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`, + 'utf8' + ) + const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) + await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') + await writeFile( + join(unpackedCliDir, 'index.js'), + [ + 'const args = process.argv.slice(2)', + "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", + "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", + 'else console.log(JSON.stringify({ executed: false }))' + ].join('\n'), + 'utf8' + ) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) + + await electronBuilderConfig.afterPack({ + appOutDir: join(root, 'linux-unpacked'), + electronPlatformName: 'linux', + arch: 1, + packager: { appInfo: { version: '9.9.9' } } + }) + + expect((await stat(launcherPath)).mode & 0o111).not.toBe(0) + await expect( + readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8') + ).resolves.toContain('"version": "9.9.9"') + await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage') + } finally { + await rm(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs index 6590596e41c..90a3f73c77d 100644 --- a/config/scripts/headless-serve-shutdown-workflow.test.mjs +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -5,6 +5,17 @@ import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8') +const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8') +const shutdownDockerRunner = readFileSync( + 'config/scripts/run-headless-serve-shutdown-docker.mjs', + 'utf8' +) +const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8') +const desktopStartupOracle = readFileSync( + 'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh', + 'utf8' +) +const headlessLinuxProse = headlessLinuxGuide.replace(/\s+/g, ' ') function readSystemdUnitBlocks(doc, unitName) { const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -40,16 +51,112 @@ describe('headless serve shutdown PR gate', () => { ).toThrow('Missing closing code fence for orca-serve.service') }) - it('packages an x64 AppImage before running the Docker signal oracle', () => { + it('packages Linux artifacts before running the Docker signal oracle', () => { const steps = workflow.jobs.package.steps const packageStep = steps.find((step) => step.name === 'Package unpacked app') + const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads') const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown') + const launcherShutdownStep = steps.find( + (step) => step.name === 'Verify extracted launcher serve signal shutdown' + ) + const appImageShutdownStep = steps.find( + (step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown' + ) - expect(packageStep.run).toContain('--linux AppImage --x64 --publish never') + expect(workflow.jobs.package['timeout-minutes']).toBe(90) + expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never') + expect(markerStep.run).toContain('dpkg-deb --fsys-tarfile') + expect(markerStep.run).toContain('rpm2cpio') + expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep)) expect(shutdownStep.run).toBe( 'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage' ) + expect(launcherShutdownStep.run).toContain( + 'node config/scripts/run-headless-serve-shutdown-docker.mjs' + ) + expect(launcherShutdownStep.run).toContain('--entrypoint launcher') + expect(appImageShutdownStep.run).toContain('--entrypoint appimage') + expect(appImageShutdownStep.run).toContain('--signal-target serving-electron') + expect(appImageShutdownStep.run).toContain('--int-delivery pid') expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep)) + expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep)) + expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep)) + expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep)) + }) + + it('keeps readiness polling finite and leak-free', () => { + expect(signalCase).toContain('read_ready_line()') + expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'") + expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}') + expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))') + expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do') + expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break') + expect(signalCase).toContain( + "jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F" + ) + expect(signalCase).not.toContain('tail --pid=') + }) + + it('gives owned shutdown state a bounded cleanup grace', () => { + expect(signalCase).toContain('for shutdown_poll in {0..50}; do') + expect(signalCase).toContain('[[ -z "$listener_after" && -z "$owned_residue" ]]') + expect(signalCase).toContain('((${#survivors[@]} == 0))') + expect(signalCase).toContain('((shutdown_poll < 50)) && sleep 0.1') + }) + + it('checks that a serving-electron signal target owns the ready socket', () => { + const ssRecord = + 'LISTEN 0 128 127.0.0.1:41235 0.0.0.0:* users:(("orca-ide",pid=23,fd=7),("orca-ide",pid=25,fd=8))' + expect([...ssRecord.matchAll(/pid=([0-9]+)/g)].map((match) => match[1])).toEqual(['23', '25']) + expect(signalCase).toContain( + 'listener_before_pids=$(grep -oE \'pid=[0-9]+\' <<<"$listener_before" | cut -d= -f2 || true)' + ) + expect(signalCase).toContain('signal_target_pid=$(head -n1 <<<"$listener_before_pids")') + expect(signalCase).toContain('outside the entrypoint process tree') + }) + + it('runs the original AppImage desktop startup oracle before extraction and signals', () => { + expect(shutdownDockerfile).toContain( + 'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case' + ) + const startupCall = shutdownDockerRunner.indexOf( + 'runDesktopStartupOracle({ image, appImage, platform })' + ) + const extractionCall = shutdownDockerRunner.indexOf( + "'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract" + ) + const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])") + expect(startupCall).toBeGreaterThan(-1) + expect(extractionCall).toBeGreaterThan(startupCall) + expect(signalLoop).toBeGreaterThan(startupCall) + expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'") + }) + + it('preserves startup logs when the launcher exits before its marker', () => { + expect(desktopStartupOracle).toContain('signal_process_group TERM || true') + expect(desktopStartupOracle).toContain('signal_process_group KILL || true') + expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true') + expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true') + expect(desktopStartupOracle).toContain( + 'FAIL: desktop launcher exited before ${reason} (status=${observed_status})' + ) + expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1') + expect(desktopStartupOracle).toContain( + '[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0' + ) + }) + + it('requires the bound AppImage to be executable before launch and extraction', () => { + expect(desktopStartupOracle).toContain( + '[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }' + ) + expect(shutdownDockerRunner).toContain( + '\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\'' + ) + }) + + it('gives the original AppImage enough bounded extraction space', () => { + expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'") }) it('keeps owned Xvfb alive during the documented systemd graceful stop', () => { @@ -63,4 +170,37 @@ describe('headless serve shutdown PR gate', () => { expect(managedXvfbUnits).toHaveLength(1) expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m) }) + + it('distinguishes persisted state from live work during a service restart', () => { + expect(headlessLinuxProse).toContain( + 'Every `systemctl stop` or `restart` therefore ends live terminals and agent processes' + ) + expect(headlessLinuxProse).toContain( + 'These guarantees do not preserve live processes. The service restart kills every terminal and agent in its cgroup' + ) + expect(headlessLinuxProse).toContain( + 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, failed request or lost connection is `unverifiable`' + ) + expect(headlessLinuxGuide).toContain( + 'sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json' + ) + expect(headlessLinuxGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json') + expect(headlessLinuxGuide).not.toContain('Two facts make this safe and predictable') + }) + + it('uses the registered CLI name from ordinary Linux shells', () => { + const commandRule = + 'The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing the GNOME Orca screen reader.' + const substitutionRule = + "From an ordinary shell outside that service user's managed environment, substitute `orca-ide` for `orca` in commands below." + const censusCommand = '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`' + + expect(headlessLinuxProse).toContain(commandRule) + expect(headlessLinuxProse).toContain(substitutionRule) + expect(headlessLinuxProse).toContain(censusCommand) + expect(headlessLinuxGuide).toContain('best-effort dispatcher at `$HOME/.local/bin/orca`') + expect(headlessLinuxProse.indexOf(substitutionRule)).toBeLessThan( + headlessLinuxProse.indexOf(censusCommand) + ) + }) }) diff --git a/config/scripts/lint-react-doctor-changed.mjs b/config/scripts/lint-react-doctor-changed.mjs index 971c28800ef..0f294f481f2 100644 --- a/config/scripts/lint-react-doctor-changed.mjs +++ b/config/scripts/lint-react-doctor-changed.mjs @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ @@ -31,11 +32,11 @@ if (lintTargets.length === 0) { process.exit(0) } -const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const { command, prefixArgs } = resolveOxlintInvocation() const result = spawnSync( - pnpm, - ['exec', 'oxlint', '--config', 'config/oxlint-react-doctor.json', ...lintTargets], - { stdio: 'inherit' } + command, + [...prefixArgs, '--config', 'config/oxlint-react-doctor.json', ...lintTargets], + { stdio: 'inherit', windowsHide: true } ) if (result.error) { diff --git a/config/scripts/linux-package-maintainer-scripts.test.mjs b/config/scripts/linux-package-maintainer-scripts.test.mjs new file mode 100644 index 00000000000..f315f2fd2ee --- /dev/null +++ b/config/scripts/linux-package-maintainer-scripts.test.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +describe('Linux package maintainer scripts', () => { + it('keeps upgrades from removing the installed CLI', () => { + const script = readFileSync( + new URL('../../resources/linux/packaging/after-remove.sh', import.meta.url), + 'utf8' + ) + const unlinkStart = script.indexOf('link="/usr/bin/orca-ide"') + const upgradeGuard = script.slice(0, unlinkStart) + + expect(unlinkStart).toBeGreaterThan(-1) + expect(upgradeGuard).toContain('case "${1-}" in') + expect(upgradeGuard).toContain('0 | remove | purge) ;;') + expect(upgradeGuard).toContain('*) exit 0 ;;') + }) +}) diff --git a/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs b/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs index bed29fe18b4..8c5c403ff74 100644 --- a/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs +++ b/config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs @@ -3,11 +3,10 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs') -const oxlintPath = path.resolve( - process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint' -) +const oxlint = resolveOxlintInvocation() function lintSource(source) { const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-')) @@ -22,9 +21,11 @@ function lintSource(source) { rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' } }) ) - const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], { - encoding: 'utf8' - }) + const result = spawnSync( + oxlint.command, + [...oxlint.prefixArgs, '--config', configPath, '--format', 'json', sourcePath], + { encoding: 'utf8', windowsHide: true } + ) if (result.error) { throw result.error } diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs new file mode 100644 index 00000000000..16013cbf0a3 --- /dev/null +++ b/config/scripts/node-pty-master-cloexec-patch.test.mjs @@ -0,0 +1,229 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + SKIP_MARKER_FILENAME, + applyNodePtyMasterCloexecPatch, + assertPatchedNodePtyMasterCloexecSource, + patchNodePtyMasterCloexecSource, + revertNodePtyMasterCloexecSource +} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs') + +// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its +// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous. +const STOCK_SOURCE = readFileSync( + resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'), + 'utf8' +) +const projectDir = resolve(import.meta.dirname, '..', '..') +const cleanupDirs = [] + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('SSH relay node-pty pty-master close-on-exec patch', () => { + it('adds the forkpty close-on-exec call and reverts to the published bytes', () => { + const fixture = writeRelayFixture() + + expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true) + const patched = readFileSync(fixture.sourcePath, 'utf8') + expect(patched).toContain('pty_cloexec(int fd)') + expect(patched).toContain('if (pty_cloexec(master) == -1)') + expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow() + + expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched) + + expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('refuses a different node-pty version or an unrecognized source', () => { + const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' }) + expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0') + + const drifted = writeRelayFixture({ + source: `${STOCK_SOURCE}\n// drift\n` + }) + expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty') + + const tampered = writeRelayFixture() + patchNodePtyMasterCloexecSource(tampered.root) + writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`) + expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed') + }) + + it('keeps the rebuilt addon once a later child no longer inherits the master', () => { + const fixture = writeRelayFixture() + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => { + calls.push('rebuild') + writeBuild(fixture, 'patched-build') + }, + verify: () => 'isolated' + }) + + expect(status).toBe('patched') + expect(calls).toEqual(['rebuild']) + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') + expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE) + expect(existsSync(fixture.backupDir)).toBe(false) + expect(existsSync(fixture.skipMarkerPath)).toBe(false) + }) + + it('keeps a rebuilt addon whose flag /proc could not confirm', () => { + const fixture = writeRelayFixture() + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => writeBuild(fixture, 'patched-build'), + verify: () => 'unverified' + }) + + expect(status).toBe('patched-unverified') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') + }) + + it('restores the working build when the compile fails, and never retries it', () => { + const fixture = writeRelayFixture() + const calls = [] + + const failed = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => { + calls.push('rebuild') + throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') + }, + verify: () => 'isolated' + }) + + expect(failed).toContain('failed:') + expect(failed).toContain('no C++ toolchain') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + expect(existsSync(fixture.backupDir)).toBe(false) + expect(existsSync(fixture.skipMarkerPath)).toBe(true) + + // Bounded, not backed off: a relay directory gets one compile attempt, ever. + const again = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + expect(again).toBe('skipped:earlier-attempt-failed') + expect(calls).toEqual(['rebuild']) + }) + + it('restores the working build when the rebuilt addon still leaks the master', () => { + const fixture = writeRelayFixture() + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => writeBuild(fixture, 'still-leaky-build'), + verify: () => { + throw new Error('rebuilt node-pty still leaks the pty master into later children') + } + }) + + expect(status).toContain('still leaks') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('never compiles on a platform that does not leak', () => { + for (const platform of ['darwin', 'win32']) { + const fixture = writeRelayFixture() + const calls = [] + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform, + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + expect(status).toBe('skipped:not-linux') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + } + }) + + it('leaves an already patched install alone', () => { + const fixture = writeRelayFixture() + patchNodePtyMasterCloexecSource(fixture.root) + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + + expect(status).toBe('already-patched') + expect(calls).toEqual([]) + }) + + it('will not rebuild an install that has no compiled addon to fall back on', () => { + const fixture = writeRelayFixture({ build: false }) + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + + expect(status).toBe('skipped:no-compiled-build') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('discards a backup stranded by an interrupted rebuild', () => { + const fixture = writeRelayFixture() + mkdirSync(fixture.backupDir, { recursive: true }) + writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build') + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'linux', + rebuild: () => writeBuild(fixture, 'patched-build'), + verify: () => 'isolated' + }) + + expect(status).toBe('patched') + expect(existsSync(fixture.backupDir)).toBe(false) + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build') + }) +}) + +function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) { + const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-')) + cleanupDirs.push(root) + const nodePtyDir = join(root, 'node_modules', 'node-pty') + const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc') + const buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node') + mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true }) + writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) + writeFileSync(sourcePath, source) + const fixture = { + root, + sourcePath, + buildPath, + backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'), + skipMarkerPath: join(root, SKIP_MARKER_FILENAME) + } + if (build) { + writeBuild(fixture, 'stock-build') + } + return fixture +} + +function writeBuild(fixture, contents) { + mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true }) + writeFileSync(fixture.buildPath, contents) +} diff --git a/config/scripts/orcad-operations-restart-safety.test.mjs b/config/scripts/orcad-operations-restart-safety.test.mjs new file mode 100644 index 00000000000..60f9cb05524 --- /dev/null +++ b/config/scripts/orcad-operations-restart-safety.test.mjs @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const operationsGuide = readFileSync('docs/reference/orcad-operations.md', 'utf8') +const operationsProse = operationsGuide.replace(/\s+/g, ' ') + +describe('orcad operations restart safety', () => { + it('distinguishes PID-scoped preservation from systemd cgroup teardown', () => { + expect(operationsProse).toContain( + 'This makes a PID-scoped update, rollback or restart non-destructive to live work' + ) + expect(operationsProse).toContain( + 'The successor adopts the current endpoint and routes supported previous protocol versions through legacy adapters' + ) + expect(operationsProse).toContain('`KillMode=mixed` does **not** preserve them') + expect(operationsProse).toContain( + '`KillMode=process` leaves service-owned processes unmanaged and is not a supported preservation mechanism' + ) + }) + + it('fails closed before cgroup-wide maintenance', () => { + expect(operationsProse).toContain( + 'A safe empty census is untruncated, has an explicit `hostScope`, covers every execution host affected by the stop, and lists no terminals on those hosts' + ) + expect(operationsProse).toContain( + "Every `omittedHostIds` entry must be explicitly accounted for outside the target service's execution boundary" + ) + expect(operationsProse).toContain( + '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`' + ) + expect(operationsGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json') + expect(operationsProse).toContain( + 'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, truncation, a failed request or lost contact makes the result `unverifiable`' + ) + expect(operationsProse).toContain('Orca does not yet provide an atomic census-and-stop fence') + }) + + it('does not refer to the unavailable shipping design', () => { + expect(operationsGuide).not.toContain('docs/design/shipping-orcad.html') + }) +}) diff --git a/config/scripts/oxlint-cli-invocation.mjs b/config/scripts/oxlint-cli-invocation.mjs new file mode 100644 index 00000000000..605aa33c686 --- /dev/null +++ b/config/scripts/oxlint-cli-invocation.mjs @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' + +// Why not `pnpm exec oxlint` / `node_modules/.bin/oxlint.cmd`: both land on a +// Windows .cmd shim, and Node >= 20 refuses to spawn one without `shell: true` +// (the CVE-2024-27980 mitigation), so every lint gate died with EINVAL before +// linting anything. Oxlint's bin is a plain Node script, so run it under this +// process's own node — no shim, no shell, no quoting question. +export function resolveOxlintInvocation(root = process.cwd()) { + const requireFromRoot = createRequire(path.join(root, 'package.json')) + // Oxlint's "exports" hides ./bin, so read the manifest and walk to its bin entry. + const manifestPath = requireFromRoot.resolve('oxlint/package.json') + const binField = requireFromRoot('oxlint/package.json').bin + const binEntry = typeof binField === 'string' ? binField : binField?.oxlint + if (!binEntry) { + throw new Error('oxlint package.json declares no "oxlint" bin entry.') + } + return { + command: process.execPath, + prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)] + } +} diff --git a/config/scripts/oxlint-cli-invocation.test.mjs b/config/scripts/oxlint-cli-invocation.test.mjs new file mode 100644 index 00000000000..7a681a825d9 --- /dev/null +++ b/config/scripts/oxlint-cli-invocation.test.mjs @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' + +const repoRoot = path.resolve(import.meta.dirname, '..', '..') + +describe('resolveOxlintInvocation', () => { + it('runs oxlint under this process node, never through a shim', () => { + const { command, prefixArgs } = resolveOxlintInvocation(repoRoot) + + expect(command).toBe(process.execPath) + expect(prefixArgs).toHaveLength(1) + // The EINVAL that killed the changed-code gate came from spawning a .cmd. + expect(prefixArgs[0]).not.toMatch(/\.(cmd|bat)$/i) + expect(existsSync(prefixArgs[0])).toBe(true) + }) + + it('spawns without a shell and produces Oxlint JSON', () => { + const { command, prefixArgs } = resolveOxlintInvocation(repoRoot) + const result = spawnSync( + command, + [...prefixArgs, '--help'], + // shell:false is the point: the shim form throws EINVAL here on Windows. + { cwd: repoRoot, encoding: 'utf8', shell: false, windowsHide: true } + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('oxlint') + }) +}) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 9f62802c84f..950d5ed258a 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -655,6 +655,8 @@ describe('Electron runtime package contract', () => { expect(releaseWindowsRunStep.run).toContain( 'pnpm run --if-present test:e2e:windows-fresh-startup-golden' ) + expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden') + expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden') expect(releaseEvidenceJob['continue-on-error']).toBe(true) expect( releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c296ad9e893..67d4f565afa 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -167,6 +167,7 @@ const SHARED_PACKAGE_PREFIXES = [ 'config/scripts/smoke-packaged', 'config/scripts/install-electron-package-binary', 'config/scripts/verify-packaged', + 'config/scripts/verify-skills-cli-runtime', 'config/scripts/verify-linux-glibc', 'config/scripts/run-electron-vite', 'skills/', @@ -180,6 +181,12 @@ const SHARED_PACKAGE_PREFIXES = [ const LINUX_PACKAGE_PREFIXES = [ ...SHARED_PACKAGE_PREFIXES, + 'config/docker/cli-launch-contract/', + 'config/docker/headless-pairing/', + 'config/docker/headless-serve-shutdown/', + 'config/scripts/run-linux-cli-launch-contract', + 'config/scripts/run-headless-linux-pairing-docker', + 'config/scripts/static-appimage-package-contract', 'native/computer-use-linux/', 'resources/linux/', 'config/scripts/run-headless-serve' diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index f9411eed956..9a1c9e649b6 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -181,6 +181,28 @@ describe('per-job path classification', () => { expectClassification(['native/computer-use-macos/Package.swift'], {}) }) + it('runs Linux packaging when an artifact contract changes', () => { + for (const file of [ + 'config/docker/cli-launch-contract/Dockerfile', + 'config/docker/cli-launch-contract/run-cli-case.sh', + 'config/docker/headless-pairing/Dockerfile', + 'config/docker/headless-pairing/run-appimage-case.sh', + 'config/docker/headless-serve-shutdown/Dockerfile', + 'config/scripts/run-linux-cli-launch-contract-docker.mjs', + 'config/scripts/run-headless-linux-pairing-docker.mjs', + 'config/scripts/static-appimage-package-contract.cjs' + ]) { + expectClassification([file], { package: true }) + } + }) + + it('runs both package jobs when the shared skills runtime verifier changes', () => { + expectClassification(['config/scripts/verify-skills-cli-runtime.cjs'], { + package: true, + package_windows: true + }) + }) + it('runs shell contracts when live-shell inputs change', () => { expectClassification(['src/main/daemon/shell-ready.ts'], { shell_contracts: true, diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 7cf2b4e11b4..ceac6b8cc6e 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -114,6 +114,7 @@ describe('PR E2E gate contract', () => { expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe( '${{ steps.filter.outputs.test_files }}' ) + expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}') expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}') }) @@ -266,6 +267,7 @@ describe('PR E2E gate contract', () => { 'tests/e2e/pty-input-write-queue-ssh.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-startup-exec-readiness.spec.ts', diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 1aed38db92e..d81f4c040fe 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -27,6 +27,7 @@ export const PR_E2E_SOURCE_ROUTES = [ 'tests/e2e/pty-input-write-queue-ssh.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-startup-exec-readiness.spec.ts', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index c69b04d663f..92d4fe4c26b 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -102,8 +102,13 @@ describe('PR workflow parallelism', () => { .split(/\s+/) .filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token)) .filter((token) => !token.startsWith('-')) - const jobsInstallingPackages = Object.entries(workflow.jobs) - .filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0)) + const requiredShells = ['zsh', 'fish'] + const jobsInstallingShells = Object.entries(workflow.jobs) + .filter(([, job]) => + (job.steps ?? []).some((step) => + aptPackages(step).some((packageName) => requiredShells.includes(packageName)) + ) + ) .map(([name]) => name) expect(shellStep).toBeDefined() @@ -111,11 +116,11 @@ describe('PR workflow parallelism', () => { expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1') // Why the whole workflow, not just the general shards: any other lane installing // these shells would silently start running the real-shell tests twice. - expect(jobsInstallingPackages).toEqual(['shell_contracts']) + expect(jobsInstallingShells).toEqual(['shell_contracts']) // Why each shell is asserted: the live tests skip themselves when the binary is // missing, so a dropped package silently empties this lane instead of failing it. const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages) - for (const shell of ['zsh', 'fish']) { + for (const shell of requiredShells) { expect(shellPackages).toContain(shell) } expect(shellInstall.with['native-runtime']).toBe('node') diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs index f3852624854..184713c41a0 100755 --- a/config/scripts/run-headless-serve-shutdown-docker.mjs +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -17,7 +17,7 @@ if (!appImageArg) { if (!['app', 'serving-electron'].includes(signalTarget)) { fail(`Unsupported --signal-target: ${signalTarget}`) } -if (!['app', 'launcher'].includes(entrypoint)) { +if (!['app', 'appimage', 'launcher'].includes(entrypoint)) { fail(`Unsupported --entrypoint: ${entrypoint}`) } if (!['pid', 'foreground-process-group'].includes(intDelivery)) { @@ -54,11 +54,19 @@ try { shutdownDockerDirectory ]) docker(['volume', 'create', artifactVolume]) + runDesktopStartupOracle({ image, appImage, platform }) docker([ 'run', '--rm', '--platform', platform, + '--network', + 'none', + '--read-only', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', '--entrypoint', 'bash', '-v', @@ -68,11 +76,17 @@ try { image, '-lc', [ - '7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null', + 'trap \'status=$?; if [ "$status" -ne 0 ]; then cat /artifacts/appimage-help.log /artifacts/appimage-extract.log 2>/dev/null || true; fi; exit "$status"\' EXIT', + 'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }', + 'timeout --kill-after=5s 15s /input/orca.AppImage --appimage-help > /artifacts/appimage-help.log 2>&1', + 'cd /artifacts', + 'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract > /artifacts/appimage-extract.log 2>&1', + 'mv squashfs-root root', launcherExecOverlay ? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide" : ':', - 'chmod -R a+rX /artifacts/root' + 'chmod -R a+rX /artifacts/root', + 'rm /artifacts/appimage-help.log /artifacts/appimage-extract.log' ].join(' && ') ]) @@ -108,6 +122,8 @@ try { '-e', `ORCA_INT_DELIVERY=${intDelivery}`, '-v', + `${appImage}:/input/orca.AppImage:ro`, + '-v', `${artifactVolume}:/artifacts:ro`, image, signal @@ -129,6 +145,38 @@ try { docker(['image', 'rm', image], { allowFailure: true }) } +function runDesktopStartupOracle({ image, appImage, platform }) { + console.log('Running original AppImage desktop startup oracle...') + docker([ + 'run', + '--rm', + '--init', + '--platform', + platform, + '--network', + 'none', + '--read-only', + '--tmpfs', + '/tmp:rw,nosuid,nodev,exec,size=1g', + '--shm-size', + '256m', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + 'orca', + '--entrypoint', + '/usr/local/bin/run-appimage-desktop-startup-case', + '-e', + 'ORCA_STARTUP_DIAGNOSTICS=1', + '-v', + `${appImage}:/input/orca.AppImage:ro`, + image, + '/input/orca.AppImage' + ]) +} + function valueAfter(flag) { const index = args.indexOf(flag) return index === -1 ? null : (args[index + 1] ?? null) diff --git a/config/scripts/run-linux-cli-launch-contract-docker.mjs b/config/scripts/run-linux-cli-launch-contract-docker.mjs new file mode 100755 index 00000000000..901e0877e85 --- /dev/null +++ b/config/scripts/run-linux-cli-launch-contract-docker.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +// Exercise packaged CLI paths under the hostile Linux conditions from #11609/#12530/#13719/#14229. +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +const commandArgs = process.argv.slice(2) +const appImageArg = valueAfter('--appimage') +const appImage = appImageArg ? resolve(appImageArg) : null +const platform = valueAfter('--platform') +const dockerPlatformArgs = platform ? ['--platform', platform] : [] + +const suffix = `${process.pid}-${Date.now()}` +const artifactVolume = `orca-cli-contract-artifact-${suffix}` +const tagArchitecture = platform?.split('/')[1] ?? process.arch +const tag = `orca-cli-launch-contract:ubuntu-24.04-${tagArchitecture}-${suffix}` +const base = 'ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90' +const containers = new Set() +let artifactVolumeCreated = false +const CASE_TIMEOUT_MS = 90_000 +const BUILD_TIMEOUT_MS = 10 * 60_000 +const STAGING_TIMEOUT_MS = 5 * 60_000 +const DOCKER_TIMEOUT_MS = 2 * 60_000 +const CLEANUP_TIMEOUT_MS = 30_000 + +// Exact statuses reject silent no-op launches as well as crashes. +const CASES = [ + { + name: 'nofuse-userns-bundled-help', + expectStatus: 0, + expectOutput: 'Usage: orca ', + why: 'The bundled launcher must run with no FUSE, no display, and userns restricted (#11609, #12530).' + }, + { + name: 'nofuse-userns-bundled-version', + expectStatus: 0, + expectOutput: /^\d+\.\d+\.\d+/m, + why: 'A deployment must be able to read the installed version without a display (#13719).' + }, + { + name: 'nofuse-userns-bundled-status', + // No runtime is running; the CLI must report that itself. + expectStatus: 1, + expectOutput: 'appRunning', + why: 'A command that needs the runtime must report its absence, not abort.' + }, + { + name: 'nofuse-userns-bundled-skills', + expectStatus: 0, + // Why: the rendered help header, not a bare 'skills' — the case name contains that word. + expectOutput: 'Usage: orca skills', + why: 'skills is a pure-text command that must never need Chromium (#14229).' + }, + { + name: 'nofuse-userns-bundled-worktree', + expectStatus: 1, + expectOutput: "Orca is not running. Run 'orca open' first.", + why: 'A runtime-dependent command must report the missing runtime, not abort.' + }, + { + name: 'nofuse-nosandbox-direct-binary-skills', + expectStatus: 0, + expectOutput: 'Usage: orca skills', + why: 'A direct binary launch that reaches JavaScript must run the command, not boot a GUI (#14229).' + }, + { + name: 'nofuse-nosandbox-direct-binary-gui', + // A missing display is an expected diagnosis, not a crash. + expectStatus: 1, + expectOutput: 'needs a usable display server', + why: 'A desktop launch with no display must diagnose it instead of dying in uv_close (#13719).' + }, + { + name: 'stale-display-nosandbox-direct-binary-gui', + expectStatus: 1, + expectOutput: 'needs a usable display server', + why: 'A stale DISPLAY value must diagnose the unreachable endpoint instead of dying in uv_close (#13719).' + } +] + +try { + if (!appImage) { + fail( + 'Usage: run-linux-cli-launch-contract-docker.mjs --appimage /path/to/orca-linux.AppImage [--platform linux/amd64|linux/arm64]' + ) + } + if (commandArgs.includes('--platform') && !platform) { + fail('Missing value for --platform') + } + if (platform !== null && platform !== 'linux/amd64' && platform !== 'linux/arm64') { + fail(`Unsupported --platform: ${platform}`) + } + if (!existsSync(appImage)) { + fail(`AppImage not found: ${appImage}`) + } + docker(['volume', 'create', artifactVolume], { timeoutMs: DOCKER_TIMEOUT_MS }) + artifactVolumeCreated = true + buildImage() + stageArtifacts() + runContract() + console.log('\nLinux CLI launch contract passed.') +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +} finally { + for (const container of containers) { + docker(['rm', '-f', container], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS }) + } + if (artifactVolumeCreated) { + docker(['volume', 'rm', artifactVolume], { + allowFailure: true, + timeoutMs: CLEANUP_TIMEOUT_MS + }) + } + docker(['image', 'rm', tag], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS }) +} + +function runContract() { + const failures = [] + for (const testCase of CASES) { + const output = runCase(testCase.name) + const statusMatch = /^RESULT status=(\d+)/m.exec(output) + if (!statusMatch) { + failures.push(`${testCase.name}: ${firstLine(output)}\n ${testCase.why}`) + console.log(` FAIL ${testCase.name} — ${firstLine(output)}`) + continue + } + const status = Number(statusMatch[1]) + // Why: the harness echoes `RESULT status=N case=`, so a case whose name contains the + // expected substring would assert against the harness's own line instead of the CLI's output. + const commandOutput = output + .split('\n') + .filter((line) => !/^(?:RESULT|CRASHED|PRECONDITION_FAILED) /.test(line)) + .join('\n') + const matchesOutput = + typeof testCase.expectOutput === 'string' + ? commandOutput.includes(testCase.expectOutput) + : testCase.expectOutput.test(commandOutput) + if (status !== testCase.expectStatus || !matchesOutput) { + failures.push( + `${testCase.name}: expected status ${testCase.expectStatus} and ${testCase.expectOutput}, ` + + `got status ${status}\n ${testCase.why}` + ) + console.log(` FAIL ${testCase.name} — status ${status}`) + continue + } + console.log(` ok ${testCase.name} (status ${status})`) + } + if (failures.length > 0) { + fail(`Linux CLI launch contract failed:\n - ${failures.join('\n - ')}`) + } +} + +function runCase(caseName) { + const container = `orca-cli-contract-${caseName}-${suffix}` + containers.add(container) + // FUSE and extra capabilities would invalidate the test conditions. + return docker( + [ + 'run', + ...dockerPlatformArgs, + '--name', + container, + '--rm', + '-v', + `${artifactVolume}:/artifacts`, + tag, + caseName + ], + { allowFailure: true, capture: true, timeoutMs: CASE_TIMEOUT_MS } + ) +} + +function buildImage() { + console.log(`Building ${tag}…`) + docker( + [ + 'build', + ...dockerPlatformArgs, + '--build-arg', + `BASE_IMAGE=${base}`, + '-f', + 'config/docker/cli-launch-contract/Dockerfile', + '-t', + tag, + 'config/docker/cli-launch-contract' + ], + { timeoutMs: BUILD_TIMEOUT_MS } + ) +} + +// Extract unprivileged so chrome-sandbox is not root-owned setuid. +function stageArtifacts() { + console.log('Staging the AppImage payload…') + const container = `orca-cli-contract-stage-${suffix}` + containers.add(container) + docker( + [ + 'run', + ...dockerPlatformArgs, + '--name', + container, + '--rm', + '-v', + `${artifactVolume}:/artifacts`, + '-v', + `${appImage}:/input/orca-linux.AppImage:ro`, + '--entrypoint', + 'bash', + tag, + '-lc', + [ + 'set -euo pipefail', + 'cp /input/orca-linux.AppImage /artifacts/orca-linux.AppImage', + 'chmod +x /artifacts/orca-linux.AppImage', + 'chown -R orca:orca /artifacts', + // Use the AppImage runtime's no-FUSE extraction path. + 'cd /artifacts && runuser --user orca -- ./orca-linux.AppImage --appimage-extract >/dev/null', + 'test -x /artifacts/squashfs-root/resources/bin/orca-ide' + ].join(' && ') + ], + { timeoutMs: STAGING_TIMEOUT_MS } + ) +} + +function docker(args, options = {}) { + try { + const output = execFileSync('docker', args, { + encoding: 'utf8', + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + timeout: options.timeoutMs ?? DOCKER_TIMEOUT_MS, + killSignal: 'SIGTERM' + }) + return output ?? '' + } catch (error) { + const timedOut = error instanceof Error && 'code' in error && error.code === 'ETIMEDOUT' + if (timedOut) { + const message = `docker ${args.join(' ')} timed out after ${options.timeoutMs ?? DOCKER_TIMEOUT_MS}ms` + if (!options.allowFailure) { + fail(message) + } + return message + } + if (!options.allowFailure) { + fail( + `docker ${args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + return `${error?.stdout ?? ''}${error?.stderr ?? ''}` + } +} + +function firstLine(value) { + return (value ?? '').trim().split('\n')[0] || '(no output)' +} + +function valueAfter(flag) { + const index = commandArgs.indexOf(flag) + return index === -1 ? null : (commandArgs[index + 1] ?? null) +} + +function fail(message) { + throw new Error(message) +} diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index a723a9a6ad0..dddfa3e0148 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -71,7 +71,9 @@ const result = spawnSync( 'tests/e2e/ssh-ai-vault-session-history.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts', 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts', 'tests/e2e/ssh-external-image-preview.spec.ts', 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', diff --git a/config/scripts/shebang-script-line-ending-pin.test.mjs b/config/scripts/shebang-script-line-ending-pin.test.mjs new file mode 100644 index 00000000000..5537d258096 --- /dev/null +++ b/config/scripts/shebang-script-line-ending-pin.test.mjs @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the `.gitattributes` pin that keeps `config/scripts` scripts on LF. + * + * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a + * pin a Windows checkout gets CRLF. Vite's SSR transform locates the shebang + * with `/^#!.*\n/` — `\r` is a JS regex line terminator, so `.` never matches it + * and the pattern misses on CRLF. The hoisted import/export preamble then lands + * at offset 0, ahead of the shebang, which in turn defeats the `code[0] === '#'` + * guard that blanks it. A literal `#!` survives into the middle of the module and + * every suite importing the script dies at load with a SyntaxError. + * + * Scoped to `config/scripts` because that is where tests import scripts. Other + * shebanged `.mjs` in the tree are spawned, not imported, so they cannot hit this. + */ +const projectDir = resolve(import.meta.dirname, '../..') +const SCRIPT_DIRECTORY = 'config/scripts' + +function git(args) { + return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' }) +} + +/** `git check-attr -z` emits NUL-separated path/attr/value triples. */ +function eolAttributes(paths) { + const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0') + const found = new Map() + for (let index = 0; index + 2 < fields.length; index += 3) { + found.set(fields[index], fields[index + 2]) + } + return found +} + +function shebangScripts() { + return git(['ls-files', '-z', '--', `${SCRIPT_DIRECTORY}/*.mjs`]) + .split('\0') + .filter(Boolean) + .filter((path) => readFileSync(join(projectDir, path), 'utf8').startsWith('#!')) +} + +describe('config/scripts line-ending pin', () => { + it('pins every shebanged script to LF', () => { + const scripts = shebangScripts() + expect(scripts.length).toBeGreaterThan(0) + + const attributes = eolAttributes(scripts) + const unpinned = scripts.filter((path) => attributes.get(path) !== 'lf') + + expect( + unpinned, + 'A shebanged script left on the platform default gets CRLF on Windows, ' + + 'which makes every suite importing it fail to load. Pin it in .gitattributes.' + ).toEqual([]) + }) + + // Why: without these the assertion above still passes against a pattern so broad + // it says nothing, or so narrow it only covers the files that exist today. + it.each([ + ['config/scripts/example.mjs', 'lf'], + ['config/scripts/nested/deeper/example.mjs', 'lf'], + ['config/scripts-extra/example.mjs', 'unspecified'], + ['vendor/config/scripts/example.mjs', 'unspecified'], + ['config/scripts/example.mjsx', 'unspecified'] + ])('resolves %s to eol=%s', (path, expected) => { + expect(eolAttributes([path]).get(path)).toBe(expected) + }) +}) diff --git a/config/scripts/skill-sharing-release-workflow.test.mjs b/config/scripts/skill-sharing-release-workflow.test.mjs index 2978b058305..8b72880e3bb 100644 --- a/config/scripts/skill-sharing-release-workflow.test.mjs +++ b/config/scripts/skill-sharing-release-workflow.test.mjs @@ -31,7 +31,7 @@ describe('skill-sharing release workflow', () => { expect(macBuild.needs).toContain('release-preflight') }) - it('blocks publication on native Windows, macOS, and the Linux floor', () => { + it('blocks on macOS and the Linux floor while keeping Windows diagnostic', () => { const platform = workflow.jobs['skill-sharing-release-gate'] const linux = workflow.jobs['skill-sharing-linux-floor-release-gate'] const publishNeeds = workflow.jobs['publish-release'].needs @@ -40,6 +40,7 @@ describe('skill-sharing release workflow', () => { { os: 'macos-15', platform: 'mac' }, { os: 'windows-2022', platform: 'windows' } ]) + expect(platform['continue-on-error']).toBe("${{ matrix.platform == 'windows' }}") expect(linux.container).toBe('ubuntu:20.04') expect(publishNeeds).toContain('skill-sharing-release-gate') expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate') diff --git a/config/scripts/static-appimage-package-contract.cjs b/config/scripts/static-appimage-package-contract.cjs new file mode 100644 index 00000000000..8a11cecf880 --- /dev/null +++ b/config/scripts/static-appimage-package-contract.cjs @@ -0,0 +1,260 @@ +const { closeSync, fstatSync, openSync, readSync } = require('node:fs') +const { basename } = require('node:path') + +const EXPECTED_ARCHITECTURE_BY_FILENAME = new Map([ + ['orca-linux.AppImage', 'x64'], + ['orca-linux-arm64.AppImage', 'arm64'] +]) +const APPIMAGE_MAGIC = Buffer.from([0x41, 0x49, 0x02]) +const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime') +const TARGET_ARCHITECTURE_BY_ENUM = new Map([ + [1, 'x64'], + [3, 'arm64'] +]) +const RUNTIME_ARCHITECTURE_BY_MACHINE = new Map([ + [0x3e, 'x64'], + [0xb7, 'arm64'] +]) +const ELF_HEADER_BYTES = 64 +const PROGRAM_HEADER_BYTES = 56 +const DYNAMIC_ENTRY_BYTES = 16 +const MAX_PROGRAM_HEADERS = 128 +const MAX_LOAD_BYTES = 16 * 1024 * 1024 +const MAX_DYNAMIC_BYTES = 1024 * 1024 + +function verifyStaticAppImagePackage(filePath, targetArch) { + const filename = basename(filePath) + const filenameArchitecture = EXPECTED_ARCHITECTURE_BY_FILENAME.get(filename) + if (!filenameArchitecture) { + invalid( + filename, + `unsupported artifact name; expected ${[...EXPECTED_ARCHITECTURE_BY_FILENAME.keys()].join(' or ')}` + ) + } + const targetArchitecture = normalizeTargetArchitecture(targetArch, filename) + if (filenameArchitecture !== targetArchitecture) { + invalid( + filename, + `artifact filename targets ${filenameArchitecture}, but electron-builder target is ${targetArchitecture}` + ) + } + + const descriptor = openSync(filePath, 'r') + try { + const stats = fstatSync(descriptor, { bigint: true }) + if (process.platform !== 'win32' && (stats.mode & 0o111n) === 0n) { + invalid(filename, 'artifact is not executable') + } + const fileSize = stats.size + const header = readRange( + descriptor, + 0n, + BigInt(ELF_HEADER_BYTES), + fileSize, + filename, + 'ELF header' + ) + const { entry, machine } = verifyElfHeader(header, filename) + const runtimeArchitecture = RUNTIME_ARCHITECTURE_BY_MACHINE.get(machine) + if (runtimeArchitecture !== targetArchitecture) { + invalid( + filename, + `runtime architecture ${runtimeArchitecture ?? `machine 0x${machine.toString(16)}`} does not match electron-builder target ${targetArchitecture}` + ) + } + + const programHeaderOffset = header.readBigUInt64LE(32) + const programHeaderSize = header.readUInt16LE(54) + const programHeaderCount = header.readUInt16LE(56) + if (programHeaderSize !== PROGRAM_HEADER_BYTES) { + invalid(filename, `unexpected ELF program-header size ${programHeaderSize}`) + } + if (programHeaderCount === 0 || programHeaderCount > MAX_PROGRAM_HEADERS) { + invalid(filename, `invalid ELF program-header count ${programHeaderCount}`) + } + + const tableSize = BigInt(programHeaderSize * programHeaderCount) + const table = readRange( + descriptor, + programHeaderOffset, + tableSize, + fileSize, + filename, + 'ELF program-header table' + ) + const segments = parseProgramHeaders(table, programHeaderSize) + verifySegments(descriptor, segments, fileSize, filename, entry) + } finally { + closeSync(descriptor) + } +} + +function verifyElfHeader(header, filename) { + if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + invalid(filename, 'missing ELF magic') + } + if (header[4] !== 2 || header[5] !== 1 || header[6] !== 1) { + invalid(filename, 'runtime must be ELF64 little-endian version 1') + } + if (!header.subarray(8, 11).equals(APPIMAGE_MAGIC)) { + invalid(filename, 'missing type-2 AppImage marker') + } + if (header.readUInt16LE(16) !== 3) { + invalid(filename, 'runtime must be an ET_DYN static PIE') + } + const machine = header.readUInt16LE(18) + if (!RUNTIME_ARCHITECTURE_BY_MACHINE.has(machine)) { + invalid(filename, `unsupported ELF machine 0x${machine.toString(16)}`) + } + if (header.readUInt32LE(20) !== 1) { + invalid(filename, 'runtime has an unsupported ELF version') + } + if (header.readUInt16LE(52) !== ELF_HEADER_BYTES) { + invalid(filename, `unexpected ELF header size ${header.readUInt16LE(52)}`) + } + return { entry: header.readBigUInt64LE(24), machine } +} + +function parseProgramHeaders(table, entrySize) { + const segments = [] + for (let offset = 0; offset < table.length; offset += entrySize) { + segments.push({ + type: table.readUInt32LE(offset), + flags: table.readUInt32LE(offset + 4), + offset: table.readBigUInt64LE(offset + 8), + virtualAddress: table.readBigUInt64LE(offset + 16), + fileSize: table.readBigUInt64LE(offset + 32), + memorySize: table.readBigUInt64LE(offset + 40) + }) + } + return segments +} + +function verifySegments(descriptor, segments, fileSize, filename, entry) { + if (segments.some((segment) => segment.type === 3)) { + invalid(filename, 'runtime contains PT_INTERP') + } + + const loadSegments = segments.filter((segment) => segment.type === 1) + const totalLoadBytes = loadSegments.reduce((total, segment) => total + segment.fileSize, 0n) + if (loadSegments.length === 0 || totalLoadBytes > BigInt(MAX_LOAD_BYTES)) { + invalid(filename, `invalid or oversized PT_LOAD data (${totalLoadBytes} bytes)`) + } + if ( + !loadSegments.some( + (segment) => + segment.flags & 1 && + entry >= segment.virtualAddress && + entry - segment.virtualAddress < segment.memorySize + ) + ) { + invalid(filename, 'ELF entry point is outside an executable PT_LOAD segment') + } + let identifiesStaticRuntime = false + for (const segment of loadSegments) { + verifyFileBackedSegment(segment, fileSize, filename, 'PT_LOAD') + const data = readRange( + descriptor, + segment.offset, + segment.fileSize, + fileSize, + filename, + 'PT_LOAD data' + ) + identifiesStaticRuntime ||= data.includes(RUNTIME_SOURCE) + } + if (!identifiesStaticRuntime) { + invalid(filename, `runtime does not identify ${RUNTIME_SOURCE.toString()}`) + } + + for (const segment of segments.filter((entry) => entry.type === 2)) { + verifyDynamicSegment(descriptor, segment, fileSize, filename) + } +} + +function normalizeTargetArchitecture(targetArch, filename) { + const architecture = + typeof targetArch === 'number' ? TARGET_ARCHITECTURE_BY_ENUM.get(targetArch) : targetArch + if (architecture !== 'x64' && architecture !== 'arm64') { + invalid(filename, `unsupported electron-builder target architecture ${String(targetArch)}`) + } + return architecture +} + +function verifyFileBackedSegment(segment, fileSize, filename, label) { + if (segment.memorySize < segment.fileSize) { + invalid(filename, `${label} memory size is smaller than its file size`) + } + verifyRange(segment.offset, segment.fileSize, fileSize, filename, label) +} + +function verifyDynamicSegment(descriptor, segment, fileSize, filename) { + verifyFileBackedSegment(segment, fileSize, filename, 'PT_DYNAMIC') + if ( + segment.fileSize === 0n || + segment.fileSize > BigInt(MAX_DYNAMIC_BYTES) || + segment.fileSize % BigInt(DYNAMIC_ENTRY_BYTES) !== 0n + ) { + invalid(filename, `invalid PT_DYNAMIC size ${segment.fileSize}`) + } + const dynamic = readRange( + descriptor, + segment.offset, + segment.fileSize, + fileSize, + filename, + 'PT_DYNAMIC data' + ) + let terminated = false + for (let offset = 0; offset < dynamic.length; offset += DYNAMIC_ENTRY_BYTES) { + const tag = dynamic.readBigInt64LE(offset) + if (tag === 0n) { + terminated = true + break + } + if (tag === 1n) { + invalid(filename, 'runtime contains a DT_NEEDED dependency') + } + } + if (!terminated) { + invalid(filename, 'PT_DYNAMIC is missing DT_NULL') + } +} + +function readRange(descriptor, offset, size, fileSize, filename, label) { + verifyRange(offset, size, fileSize, filename, label) + const buffer = Buffer.alloc(Number(size)) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const count = readSync( + descriptor, + buffer, + bytesRead, + buffer.length - bytesRead, + Number(offset) + bytesRead + ) + if (count === 0) { + throw new Error(`Unable to read complete ${label}`) + } + bytesRead += count + } + return buffer +} + +function verifyRange(offset, size, fileSize, filename, label) { + const maxSafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + if ( + offset > fileSize || + size > fileSize - offset || + offset > maxSafeOffset || + size > maxSafeOffset - offset + ) { + invalid(filename, `${label} is outside the artifact`) + } +} + +function invalid(filename, reason) { + throw new Error(`Invalid static AppImage ${filename}: ${reason}`) +} + +module.exports = { verifyStaticAppImagePackage } diff --git a/config/scripts/static-appimage-package-contract.test.mjs b/config/scripts/static-appimage-package-contract.test.mjs new file mode 100644 index 00000000000..2addc675482 --- /dev/null +++ b/config/scripts/static-appimage-package-contract.test.mjs @@ -0,0 +1,225 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { verifyStaticAppImagePackage } = require('./static-appimage-package-contract.cjs') + +const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime') +const LOAD_HEADER = 64 +const DYNAMIC_HEADER = 120 +const DYNAMIC_OFFSET = 320 +const FIXTURE_BYTES = 384 + +describe('static AppImage package contract', () => { + it.each([ + ['orca-linux.AppImage', 0x3e, 1], + ['orca-linux-arm64.AppImage', 0xb7, 'arm64'] + ])('accepts a dependency-free type-2 %s runtime', async (filename, machine, targetArch) => { + await withFixture(filename, createRuntime({ machine }), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).not.toThrow() + }) + }) + + it.each([ + ['generic filename for an arm64 runtime and target', 'orca-linux.AppImage', 0xb7, 3], + ['arm64 filename for an x64 runtime and target', 'orca-linux-arm64.AppImage', 0x3e, 1], + ['generic x64 runtime for an arm64 target', 'orca-linux.AppImage', 0x3e, 3], + ['generic arm64 runtime for an x64 target', 'orca-linux.AppImage', 0xb7, 1], + ['arm64 artifact filename for an x64 target', 'orca-linux-arm64.AppImage', 0xb7, 1], + ['x64 runtime under an arm64 artifact filename', 'orca-linux-arm64.AppImage', 0x3e, 3] + ])('rejects %s', async (_label, filename, machine, targetArch) => { + await withFixture(filename, createRuntime({ machine }), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/architecture|target/) + }) + }) + + it.each([undefined, 0, 'ia32'])( + 'rejects unsupported target architecture %s', + async (targetArch) => { + await withFixture('orca-linux.AppImage', createRuntime(), (path) => { + expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/target architecture/) + }) + } + ) + + it('accepts PT_DYNAMIC relocation metadata without dependencies', async () => { + const runtime = createRuntime() + runtime.writeBigInt64LE(7n, DYNAMIC_OFFSET) + await withFixture('orca-linux.AppImage', runtime, (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow() + }) + }) + + it('does not scan the appended AppImage payload as outer ELF data', async () => { + const payload = Buffer.concat([RUNTIME_SOURCE, Buffer.alloc(16, 1)]) + await withFixture('orca-linux.AppImage', Buffer.concat([createRuntime(), payload]), (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow() + }) + + const unidentifiedRuntime = createRuntime() + unidentifiedRuntime.fill(0, 192, 192 + RUNTIME_SOURCE.length) + await withFixture( + 'orca-linux.AppImage', + Buffer.concat([unidentifiedRuntime, payload]), + (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/does not identify/) + } + ) + }) + + it('rejects artifact names outside the release contract before reading them', () => { + expect(() => verifyStaticAppImagePackage('/missing/orca-preview.AppImage')).toThrow( + 'unsupported artifact name' + ) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a readable but non-executable AppImage', + async () => { + await withFixture( + 'orca-linux.AppImage', + createRuntime(), + (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/not executable/) + }, + { mode: 0o644 } + ) + } + ) + + it.each([ + [ + 'non-ELF64 runtimes', + (runtime) => { + runtime[4] = 1 + }, + /ELF64 little-endian/ + ], + [ + 'unsupported ELF versions', + (runtime) => runtime.writeUInt32LE(2, 20), + /unsupported ELF version/ + ], + [ + 'non-type-2 AppImages', + (runtime) => { + runtime[10] = 1 + }, + /type-2 AppImage marker/ + ], + ['non-PIE runtimes', (runtime) => runtime.writeUInt16LE(2, 16), /ET_DYN static PIE/], + [ + 'unsupported architectures', + (runtime) => runtime.writeUInt16LE(3, 18), + /unsupported ELF machine/ + ], + ['dynamic loaders', (runtime) => runtime.writeUInt32LE(3, DYNAMIC_HEADER), /PT_INTERP/], + [ + 'shared-library dependencies', + (runtime) => runtime.writeBigInt64LE(1n, DYNAMIC_OFFSET), + /DT_NEEDED/ + ], + [ + 'unidentified runtimes', + (runtime) => runtime.fill(0, 192, 192 + RUNTIME_SOURCE.length), + /does not identify/ + ], + [ + 'out-of-bounds load segments', + (runtime) => { + runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 32) + runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 40) + }, + /outside the artifact/ + ], + [ + 'oversized load claims', + (runtime) => { + runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 32) + runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 40) + }, + /oversized PT_LOAD/ + ], + [ + 'non-executable entry segments', + (runtime) => runtime.writeUInt32LE(4, LOAD_HEADER + 4), + /executable PT_LOAD/ + ], + [ + 'entry points outside load segments', + (runtime) => runtime.writeBigUInt64LE(4096n, 24), + /entry point/ + ] + ])('rejects %s', async (_label, mutate, expected) => { + const runtime = createRuntime() + mutate(runtime) + await withFixture('orca-linux.AppImage', runtime, (path) => { + expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(expected) + }) + }) +}) + +function createRuntime({ machine = 0x3e } = {}) { + const runtime = Buffer.alloc(FIXTURE_BYTES) + Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(runtime) + Buffer.from([0x41, 0x49, 0x02]).copy(runtime, 8) + runtime.writeUInt16LE(3, 16) + runtime.writeUInt16LE(machine, 18) + runtime.writeUInt32LE(1, 20) + runtime.writeBigUInt64LE(0n, 24) + runtime.writeBigUInt64LE(64n, 32) + runtime.writeUInt16LE(64, 52) + runtime.writeUInt16LE(56, 54) + runtime.writeUInt16LE(2, 56) + + writeProgramHeader(runtime, LOAD_HEADER, { + type: 1, + flags: 5, + offset: 0, + virtualAddress: 0, + size: FIXTURE_BYTES, + memorySize: FIXTURE_BYTES, + alignment: 4096 + }) + writeProgramHeader(runtime, DYNAMIC_HEADER, { + type: 2, + flags: 4, + offset: DYNAMIC_OFFSET, + virtualAddress: DYNAMIC_OFFSET, + size: 32, + memorySize: 32, + alignment: 8 + }) + RUNTIME_SOURCE.copy(runtime, 192) + return runtime +} + +function writeProgramHeader( + runtime, + headerOffset, + { type, flags, offset, virtualAddress, size, memorySize = size, alignment } +) { + runtime.writeUInt32LE(type, headerOffset) + runtime.writeUInt32LE(flags, headerOffset + 4) + runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 8) + runtime.writeBigUInt64LE(BigInt(virtualAddress), headerOffset + 16) + runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 24) + runtime.writeBigUInt64LE(BigInt(size), headerOffset + 32) + runtime.writeBigUInt64LE(BigInt(memorySize), headerOffset + 40) + runtime.writeBigUInt64LE(BigInt(alignment), headerOffset + 48) +} + +async function withFixture(filename, contents, check, { mode = 0o755 } = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-static-appimage-contract-')) + try { + const path = join(root, filename) + await writeFile(path, contents) + await chmod(path, mode) + await check(path) + } finally { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/config/scripts/verify-cli-bin.mjs b/config/scripts/verify-cli-bin.mjs index a9fa71ce2e7..cdc56401262 100755 --- a/config/scripts/verify-cli-bin.mjs +++ b/config/scripts/verify-cli-bin.mjs @@ -5,15 +5,14 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'nod import path from 'node:path' import { pathToFileURL } from 'node:url' -const OUT_COMMONJS_PACKAGE_JSON = `${JSON.stringify( - { - name: 'orca-compiled-output', - type: 'commonjs', - private: true - }, - null, - 2 -)}\n` +// Electron packaging restamps the channel-specific version after compilation. +function buildOutPackageJson(version) { + return `${JSON.stringify( + { name: 'orca-compiled-output', type: 'commonjs', private: true, version }, + null, + 2 + )}\n` +} /** * Verifies the published CLI entrypoint and the module-type boundary for the @@ -49,7 +48,7 @@ export function verifyPackageCliBin({ const outPackageJsonPath = path.join(projectDir, 'out', 'package.json') if (fixPackageJson) { mkdirSync(path.dirname(outPackageJsonPath), { recursive: true }) - writeFileSync(outPackageJsonPath, OUT_COMMONJS_PACKAGE_JSON, 'utf8') + writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8') } let outPackageJson try { diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index 55ec8ca7724..3a138ed894b 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -164,6 +164,78 @@ function findMissingProviderDeps(importedSymbols, neededLibraries) { return missing } +// ELF e_machine values for the Linux slices we package. Names match electron-builder's Arch enum. +const ELF_MACHINE_BY_ARCH = Object.freeze({ x64: 0x3e, arm64: 0xb7 }) +const ARCH_BY_ELF_MACHINE = Object.freeze({ 0x3e: 'x64', 0xb7: 'arm64' }) + +/** + * ELF `e_machine`, or null when the file is not a readable little-endian ELF. + * + * Why this is checked at all: cross-building an arm64 package on an x64 host can silently pack an + * x86-64 `pty.node` into the arm64 slice — the rebuild logs a forced arm64 rebuild and still ships + * the host's binary. Every other gate here inspects symbol versions, which are perfectly valid on + * the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the app loaded, then + * failed with "Failed to load native module: pty.node". + */ +function readElfMachine(filePath) { + let fd + try { + fd = openSync(filePath, 'r') + const header = Buffer.alloc(20) + if (readSync(fd, header, 0, 20, 0) !== 20) { + return null + } + // EI_DATA (offset 5) must be ELFDATA2LSB for a little-endian e_machine read. + if (header[5] !== 1) { + return null + } + return header.readUInt16LE(18) + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +// Arch tokens that appear in vendored per-architecture package/directory names. +const ARCH_TOKEN_PATTERN = /(?:^|[^a-z0-9])(arm64|aarch64|x64|x86_64)(?:[^a-z0-9]|$)/i +const ARCH_BY_TOKEN = Object.freeze({ arm64: 'arm64', aarch64: 'arm64', x64: 'x64', x86_64: 'x64' }) + +/** + * The architecture a path advertises, or null when it advertises none. + * + * Why this matters: some dependencies ship every architecture and let their loader pick + * (`@parcel/watcher-linux-arm64-glibc/watcher.node` is arm64 on purpose inside an x64 build). Those + * must be judged against the arch their own path declares, not against the slice. + */ +function declaredArchFromPath(filePath) { + const match = ARCH_TOKEN_PATTERN.exec(filePath) + return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null +} + +function findArchViolation(filePath, targetArch) { + // A path that names an architecture is judged against that name, so a per-arch vendored package + // is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught. + const declared = declaredArchFromPath(filePath) + const expectedArch = declared ?? targetArch + const expected = ELF_MACHINE_BY_ARCH[expectedArch] + if (expected === undefined) { + return null + } + const machine = readElfMachine(filePath) + if (machine === null || machine === expected) { + return null + } + return { + machine, + actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}`, + expectedArch, + declared: declared !== null + } +} + function isElfFile(filePath) { let fd try { @@ -312,6 +384,7 @@ function readImportedSymbols(filePath, objdumpPath) { */ function verifyLinuxGlibcFloor(rootDir, options = {}) { const binaries = collectNativeBinaries(rootDir) + const targetArch = options.targetArch if (binaries.length === 0) { console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`) return @@ -327,6 +400,28 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { ) } + // Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but + // meaningless, so reporting a floor violation for it would send the reader down the wrong path. + const archOffenders = binaries + .map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) })) + .filter(({ violation }) => violation !== null) + if (archOffenders.length > 0) { + const detail = archOffenders + .map( + ({ filePath, violation }) => + ` ${relative(rootDir, filePath) || filePath} is ${violation.actual}, expected ` + + `${violation.expectedArch}${violation.declared ? ' (from its own path)' : ''}` + ) + .join('\n') + throw new Error( + `[verify-linux-glibc-floor] ${archOffenders.length} bundled native binar` + + `${archOffenders.length === 1 ? 'y is' : 'ies are'} built for the wrong architecture ` + + `(target ${targetArch}), so the app will fail to load them at runtime:\n${detail}\n` + + 'Cross-building a Linux slice can pack the host architecture despite a forced rebuild; ' + + 'build this slice on a native runner.' + ) + } + const offenders = [] for (const filePath of binaries) { const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath) @@ -375,6 +470,10 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { module.exports = { MIN_GLIBC, + ELF_MACHINE_BY_ARCH, + readElfMachine, + declaredArchFromPath, + findArchViolation, VERSION_FLOORS, FLOOR_LABEL, RELOCATED_SYMBOL_PROVIDERS, diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs index 603e4e85c00..d8d82165053 100644 --- a/config/scripts/verify-linux-glibc-floor.test.mjs +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -6,6 +6,10 @@ import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const { + readElfMachine, + declaredArchFromPath, + findArchViolation, + ELF_MACHINE_BY_ARCH, parseGlibcVersion, compareGlibcVersions, parseVersionNeeds, @@ -321,3 +325,86 @@ describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => { } }) }) + +/** Minimal little-endian 64-bit ELF header with the given e_machine. */ +function elfHeader(machine) { + const header = Buffer.alloc(64) + header.write('\x7fELF', 0, 'latin1') + header[4] = 2 // ELFCLASS64 + header[5] = 1 // ELFDATA2LSB + header[6] = 1 // EV_CURRENT + header.writeUInt16LE(3, 16) // ET_DYN + header.writeUInt16LE(machine, 18) + return header +} + +describe('bundled native binary architecture', () => { + it('reads e_machine from a little-endian ELF', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64)) + expect(readElfMachine(file)).toBe(ELF_MACHINE_BY_ARCH.arm64) + await rm(dir, { recursive: true, force: true }) + }) + + // The observed failure: cross-building arm64 on an x64 host packed an x86-64 pty.node, whose + // symbol versions are valid, so every other gate here passed it. + // Real CI hit: @parcel/watcher ships every architecture and its loader picks the match, so the + // arm64 copy is present in an x64 build on purpose. + it('accepts a per-arch vendored package that matches its own path', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const pkg = join(dir, '@parcel', 'watcher-linux-arm64-glibc') + await mkdir(pkg, { recursive: true }) + const file = join(pkg, 'watcher.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64)) + expect(declaredArchFromPath(file)).toBe('arm64') + expect(findArchViolation(file, 'x64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + // But a path that names an arch must actually hold it — this is the Pi 5 failure. + it('flags a binary that contradicts the architecture its own path names', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const nested = join(dir, 'bin', 'linux-arm64-148') + await mkdir(nested, { recursive: true }) + const file = join(nested, 'node-pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' }) + // Still caught even when the slice being built is x64. + expect(findArchViolation(file, 'x64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' }) + await rm(dir, { recursive: true, force: true }) + }) + + it('flags an x86-64 binary in an arm64 slice', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64' }) + await rm(dir, { recursive: true, force: true }) + }) + + it('accepts a matching architecture', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'x64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + it('stays silent when no target architecture is supplied', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, undefined)).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + it('ignores a file that is not a readable little-endian ELF', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const file = join(dir, 'not-elf.node') + await writeFile(file, Buffer.from('not an elf at all')) + expect(readElfMachine(file)).toBeNull() + expect(findArchViolation(file, 'arm64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) +}) diff --git a/config/scripts/win32-test-lane-registration.test.mjs b/config/scripts/win32-test-lane-registration.test.mjs new file mode 100644 index 00000000000..a1566c55c31 --- /dev/null +++ b/config/scripts/win32-test-lane-registration.test.mjs @@ -0,0 +1,673 @@ +import { readFileSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { scanSourceTree, stripComments } from '../../src/shared/source-scan/source-tree-scan' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +/** + * Every Windows-gated test file must be registered in BOTH Windows-lane lists. + * + * PR CI has exactly one job on a Windows runner -- asserted below on any + * `runs-on` spelling that could land there, because that premise is what makes + * this guard meaningful -- and it runs a curated explicit file list. Everything else runs on `ubuntu-latest`, where a Windows-gated + * suite self-skips and reports success. So a new Windows-gated file that nobody + * registers executes on no machine and passes green, silently. A recent + * security effort added six such files; five ran nowhere, including one whose + * whole point was asserting a native addon's bytes no longer contain a flagged + * primitive. Registering the instances did not hold -- a sixth arrived from + * unrelated work while the first five were being fixed -- so the class needs a + * guard. + * + * Both lists matter and being in one is not enough: `WINDOWS_PACKAGE_TESTS` in + * pr-code-change-scope.mjs decides whether the `package_windows` job RUNS at + * all for a diff, and the workflow step's vitest argv decides whether the FILE + * runs once the job started. + * + * WHAT THIS DETECTS -- a file is Windows-gated when its name is `*.win32.test.*` + * / `*.win32.spec.*`, or when it contains ANY suite-level gate, nested ones + * included, spelled: + * - `describe.runIf()`, `describe.skipIf()` + * - `const d = ? describe : describe.skip`, and the + * `? describe.skip : describe` inversion + * where the condition is `process.platform === 'win32'` / `!== 'win32'`, a + * compound ` && `, or a `const`/`let` in the same file + * assigned from either -- so `const RUN_REAL = platform === 'win32' && env…` + * used as `describe.runIf(RUN_REAL)` is detected, whatever the flag is named + * and whichever polarity it was written in. Quote style, spacing and the + * `describe`/`suite` spelling are tolerated. Nested gates count because the + * Windows lane runs whole files: a win32-only block buried three levels down + * still runs on no machine unless the file is registered. + * + * WHAT THIS CANNOT DETECT -- known blind spots, each deliberate: + * - `it`/`test`-level gates. A single win32-only case inside a cross-platform + * suite still leaves the file running its other cases on ubuntu, and + * pulling all such files -- about thirty, though the figure moves with + * which gate spellings you count, so do not lean on it -- into the serial + * Windows job is not the trade CI wants. This is the largest limit, and it + * is a policy choice, not an oversight: a suite-level gate means a whole + * block exists only for Windows, which is the shape worth a lane entry. + * - a gate whose condition crosses a module boundary or a function call -- + * an imported flag, an imported `describeOnWindows`, `isWindows()`. + * `legacy-wsl-runtime-auth-drain-apply-script.test.ts` imports its + * `isWindows`; it happens to be a POSIX-only gate, so nothing is missed + * today, but a win32-only one written that way would be. + * - `runIf( || )` and `skipIf( && )` are rejected on + * purpose: both can run off Windows, so neither is a win32-only gate. That + * holds whether the condition is written at the gate or routed through a + * named flag -- the two spellings used to disagree. + * - whether a registered suite EXECUTES. Registration is what is asserted. A + * suite gated on win32 plus an env var stays skipped on the CI runner even + * when registered -- see MANUAL_OPT_IN -- and a path registered but gated + * for another platform is not caught either. + * - whether the `package_windows` job is triggered for a given diff, or + * whether the registered test asserts anything worth running. + * + * Growth of the two grandfathered lists is capped by literals, but only review + * stops someone raising a cap. The caps make that an explicit, visible edit. + */ + +const projectDir = resolve(import.meta.dirname, '../..') +const WINDOWS_LANE_JOB = 'package_windows' +const WINDOWS_LANE_STEP = 'Test Windows-specific boundaries' +const WINDOWS_LANE_RUNNER = 'windows-2022' + +/** + * Windows-gated files that predate this guard and are registered in neither + * list. Shrink-only: registering one means deleting its line here. Never add. + */ +const UNREGISTERED_ON_MAIN = [ + // Suite gated with `describe.skipIf(platform !== 'win32')`; the cross-platform + // half of the file still runs on ubuntu, the Windows half runs nowhere. + 'src/main/antigravity/windows-hook-payload-delivery.test.ts', + // `.win32.test.ts` by name yet in neither list -- the plainest instance of the class. + 'src/main/daemon/node-pty-windows-input-error.win32.test.ts', + // Same shape as the antigravity file: a win32-only sibling suite that never runs. + 'src/main/grok/windows-grok-hook-script.test.ts', + // Whole file is `describe.runIf(platform === 'win32')`; runs on no machine. + 'src/main/ipc/preflight-windows-path-refresh.repro.test.ts', + // Nested `describe.skipIf(!isWindows)` real-shell block; never exercised in CI. + 'src/main/ipc/pty-encoding.test.ts', + // `describeWindows` ternary over the whole file; runs on no machine. + 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts', + // Whole file is `describe.runIf(platform === 'win32')`; runs on no machine. + 'src/main/startup/windows-shell-path-restoration.windows.test.ts', + // Whole file is `describe.skipIf(platform !== 'win32')`; runs on no machine. + 'src/shared/setup-agent-sequencing.windows.test.ts' +] + +/** + * Windows-gated suites that ALSO require an opt-in env var, so registering them + * would not make them execute -- they are run by hand against a real distro or + * a real filesystem. Excluded deliberately and visibly rather than by accident + * of a regex; each entry is asserted below to be genuinely env-gated, so this + * list cannot become a place to park a file someone did not want to register. + */ +const MANUAL_OPT_IN = [ + // `runIf(platform === 'win32' && Boolean(distro))`, distro from ORCA_TEST_WSL_DISTRO. + 'src/main/git/runner-wsl-linked-gitdir-windows.test.ts', + // `runRealWsl = … && ORCA_REAL_WSL_BANNER_TEST === '1'`; needs a real distro. + 'src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts', + // `RUN_REAL_WINDOWS = platform === 'win32' && ORCA_REAL_WINDOWS_SKILL_TEST === '1'`. + 'src/main/skills/skill-windows-rename-contention.integration.test.ts', + // Same flag; installs into a real Windows workspace. + 'src/main/skills/skill-windows-workspace.integration.test.ts', + // `RUN_REAL_WSL = … && ORCA_REAL_WSL_SKILL_TEST === '1'`; real distro filesystem. + 'src/main/skills/skill-wsl-delete.integration.test.ts', + // Same flag; real WSL install transactions. + 'src/main/skills/skill-wsl-install-transaction.integration.test.ts', + // Same flag; real WSL POSIX semantics. + 'src/main/skills/skill-wsl-posix-semantics.integration.test.ts', + // `runRealWsl = … && ORCA_REAL_WSL_DELETE_TEST === '1'`; real distro traversal race. + 'src/main/wsl-approved-root-race.wsl.test.ts', + // Same flag; real UNC delete against a distro. + 'src/main/wsl-unc-delete.wsl.test.ts', + // `enabled = platform === 'win32' && ORCA_REAL_WSL_RUNNER_TEST === '1'`; mutates a real distro's ~/.profile. + 'src/main/wsl/wsl-runner.wsl.test.ts' +] + +/** Caps so growing either list is two deliberate edits, not one. */ +const UNREGISTERED_MAX = 8 +const MANUAL_OPT_IN_MAX = 10 + +/** + * Floor for the Windows-gated population, so a broken walk or a regex that + * stops matching cannot make the guard pass by finding nothing. Only ever + * lowered, and only when a gated file is genuinely deleted. + */ +const GATED_FILE_FLOOR = 23 + +const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:ts|tsx|mjs|cjs|js)$/ + +/** + * Mobile has its own vitest run and never touches the desktop Windows job: + * `classifyPrJobs` reports `package_windows: false` for every `mobile/` path, + * so a gated file there could not satisfy this guard even in principle. + */ +const UNREACHABLE_BY_THE_WINDOWS_LANE = 'mobile/' + +/** + * This file quotes every gate spelling as a fixture, so it matches its own + * matcher. It is not gated -- it must run on ubuntu, since a guard about + * Windows CI that only ran on Windows would be self-defeating. Exempt by exact + * path, never by directory, so a real gated file in config/scripts is caught. + */ +const SCANNER_SELF_PATH = 'config/scripts/win32-test-lane-registration.test.mjs' + +export function isScannerSelfPath(path) { + return path === SCANNER_SELF_PATH +} + +const WIN32_TRUE_EXPRESSION = String.raw`process\.platform\s*===\s*['"]win32['"]` +const WIN32_FALSE_EXPRESSION = String.raw`process\.platform\s*!==\s*['"]win32['"]` +const SUITE = String.raw`(?:describe|suite)` + +/** + * Named flags resolved from their assignment in the same file, so polarity is + * read rather than guessed from the name. + * + * Why the trailing lookahead: `const d = platform === 'win32' ? describe : …` + * is a suite alias, not a boolean, and must not be collected as one. + * + * Why the two patterns differ on `&&`: a second conjunct NARROWS a + * truthy-on-Windows flag, which stays Windows-only, but WIDENS a + * falsy-on-Windows one -- `p = platform !== 'win32' && x` used as `skipIf(p)` + * runs on Windows AND on POSIX whenever `x` is false, so it is not a + * Windows-only gate. One lookahead shared across both polarities had that + * backwards, and routing the condition through a named flag flipped the answer + * the literal form got right. `||` is excluded from both. + */ +const FLAG_TRUE_ASSIGNMENT = new RegExp( + String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${WIN32_TRUE_EXPRESSION}(?=\s*(?:&&|;|\r?\n|$))`, + 'g' +) +const FLAG_FALSE_ASSIGNMENT = new RegExp( + String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${WIN32_FALSE_EXPRESSION}(?=\s*(?:;|\r?\n|$))`, + 'g' +) + +function escapeForAlternation(name) { + return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** Never-matching branch, so an empty flag set cannot widen a pattern. */ +const MATCHES_NOTHING = String.raw`(?!)` + +function alternation(names) { + return names.length === 0 ? MATCHES_NOTHING : names.map(escapeForAlternation).join('|') +} + +function buildGates(source) { + const trueOnWindows = [...source.matchAll(FLAG_TRUE_ASSIGNMENT)].map(([, name]) => name) + const falseOnWindows = [...source.matchAll(FLAG_FALSE_ASSIGNMENT)].map(([, name]) => name) + const isTrue = `(?:${WIN32_TRUE_EXPRESSION}|\\b(?:${alternation(trueOnWindows)})\\b)` + const isFalse = `(?:${WIN32_FALSE_EXPRESSION}|!\\s*(?:${alternation(trueOnWindows)})\\b|\\b(?:${alternation(falseOnWindows)})\\b)` + return [ + // `\)` or `&&` after the condition: a bare gate, or a compound one whose + // remaining conjuncts only narrow it further. Anchoring on `\)` alone was + // this guard's own bug -- `runIf(win32 && hasAddon)` went undetected. + new RegExp(String.raw`\b${SUITE}\s*\.\s*runIf\s*\(\s*${isTrue}\s*(?:\)|&&)`), + new RegExp(String.raw`\b${SUITE}\s*\.\s*skipIf\s*\(\s*${isFalse}\s*(?:\)|\|\|)`), + // `(?!\s*\.\s*skip)`: `platform === 'win32' ? describe.skip : describe` is + // the POSIX-only gate, the exact opposite of the class, and seven files + // use it. + new RegExp(String.raw`=\s*${isTrue}\s*\?\s*${SUITE}\s*(?!\s*\.\s*skip)`), + new RegExp(String.raw`=\s*${isFalse}\s*\?\s*${SUITE}\s*\.\s*skip`) + ] +} + +/** Exported shape of the rule, so the fixtures below exercise the real matcher. */ +export function isWindows32GatedTestFile(path, source) { + if (/\.win32\.(?:test|spec)\./.test(path)) { + return true + } + // Prose about a gate is not a gate; the shared stripper tracks quote state so + // a slash-star inside a string cannot blank live code. + const code = stripComments(source) + return buildGates(code).some((gate) => gate.test(code)) +} + +/** + * True when the env read REACHES the gate: the win32 check is compound, and one + * of its other conjuncts either reads `process.env` itself or names a const + * that does. + * + * "Mentions an env var anywhere in the file" is not enough and was the earlier + * bug here. `runIf(platform === 'win32' && hasAddon)` in a file that happens to + * read `process.env.RUNNER_TEMP` for a temp dir is a test CI COULD run -- the + * native-addon-bytes shape, exactly what this effort exists to keep in CI -- + * and it would have parked in MANUAL_OPT_IN unnoticed. Only the cap number + * stood in the way, and a number is not an argument. + * + * One hop is enough for every real case: `distro = process.env.ORCA_TEST_WSL_DISTRO` + * then `runIf(platform === 'win32' && Boolean(distro))`. Deeper chains fail + * closed -- the file reads as registrable, which is the safe direction. + */ +const WIN32_CONJUNCT = new RegExp(String.raw`${WIN32_TRUE_EXPRESSION}\s*&&([^\n]*)`, 'g') +const ENV_READ = /process\.env\.[A-Za-z0-9_]+/ +const IDENTIFIER = /[A-Za-z_$][\w$]*/g + +function isAssignedFromEnv(name, code) { + return new RegExp( + String.raw`(?:const|let|var)\s+${escapeForAlternation(name)}\s*=[^\n]*process\.env\.` + ).test(code) +} + +export function requiresEnvOptIn(source) { + const code = stripComments(source) + return [...code.matchAll(WIN32_CONJUNCT)].some(([, conjunct]) => { + if (ENV_READ.test(conjunct)) { + return true + } + return [...conjunct.matchAll(IDENTIFIER)].some(([name]) => isAssignedFromEnv(name, code)) + }) +} + +/** + * Any `runs-on` that could put a job on Windows. + * + * Not an equality test against `windows-2022`: `windows-latest` resolves to the + * same image today, a label array or `{ group, labels }` object is valid YAML + * here, and a `${{ matrix.os }}` expression cannot be resolved from the file at + * all. An unresolvable expression counts as "could be Windows" so it fails + * closed -- someone has to look rather than have a second lane appear silently. + */ +export function couldRunOnWindows(runsOn) { + const labels = + typeof runsOn === 'string' + ? [runsOn] + : Array.isArray(runsOn) + ? runsOn + : [...(runsOn?.labels ?? []), runsOn?.group ?? ''].flat() + return labels.some((label) => /windows/i.test(String(label)) || String(label).includes('${{')) +} + +/** The vitest argv of the one Windows job's one curated-file step. */ +function readWindowsWorkflow() { + const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const jobs = Object.entries(workflow.jobs ?? {}) + const windowsJobs = jobs.filter(([, job]) => couldRunOnWindows(job?.['runs-on'])) + const steps = workflow.jobs?.[WINDOWS_LANE_JOB]?.steps ?? [] + const step = steps.find((candidate) => candidate?.name === WINDOWS_LANE_STEP) + if (!step) { + throw new Error( + `No "${WINDOWS_LANE_STEP}" step in the ${WINDOWS_LANE_JOB} job of .github/workflows/pr.yml. ` + + 'If it was renamed, update WINDOWS_LANE_STEP here -- do not delete this guard.' + ) + } + const run = String(step.run ?? '') + if (!run.includes('vitest run')) { + throw new Error( + `The "${WINDOWS_LANE_STEP}" step no longer invokes vitest; this guard is stale.` + ) + } + return { + windowsJobNames: windowsJobs.map(([name]) => name), + laneFiles: run.split(/\s+/).filter((token) => TEST_FILE_PATTERN.test(token)) + } +} + +const { windowsJobNames, laneFiles } = readWindowsWorkflow() +const scannedTestFiles = scanSourceTree(projectDir, { + includeTests: true, + extensions: TEST_FILE_PATTERN +}).filter(({ relativePath }) => !relativePath.startsWith(UNREACHABLE_BY_THE_WINDOWS_LANE)) +const gatedFiles = scannedTestFiles + .filter(({ relativePath }) => !isScannerSelfPath(relativePath)) + .filter(({ relativePath, source }) => isWindows32GatedTestFile(relativePath, source)) + .map(({ relativePath }) => relativePath) + +/** + * Why the classifier and not the literal list: `WINDOWS_PACKAGE_TESTS` is not + * exported, and the classifier is what CI actually consults. It inherits + * `classifyPrJobs`'s force-all, so a path under GLOBAL_FORCE_PREFIXES would + * read as registered without being listed -- no test file is one today. + */ +function isInClassifier(path) { + return classifyPrJobs([path])[WINDOWS_LANE_JOB] === true +} + +function registrationFailure(path) { + const missing = [] + if (!laneFiles.includes(path)) { + missing.push( + `add "${path}" to the "${WINDOWS_LANE_STEP}" vitest argv in .github/workflows/pr.yml ` + + `(job ${WINDOWS_LANE_JOB})` + ) + } + if (!isInClassifier(path)) { + missing.push( + `add '${path}' to WINDOWS_PACKAGE_TESTS in config/scripts/pr-code-change-scope.mjs` + ) + } + return missing.length === 0 ? null : `${path}: ${missing.join('; and ')}` +} + +function sourceOf(path) { + return readFileSync(join(projectDir, path), 'utf8') +} + +describe('Windows-gated test files are registered in the Windows CI lane', () => { + it('scans a plausible number of test files', () => { + // A broken root or extension filter would make every assertion below vacuous. + expect(scannedTestFiles.length).toBeGreaterThan(5000) + }) + + it('has exactly one windows-2022 job to register into', () => { + // The whole premise: one Windows lane, one curated list. A second lane would + // mean a file could be registered in the wrong one and still run nowhere. + expect( + windowsJobNames, + `Expected only ${WINDOWS_LANE_JOB} to run on ${WINDOWS_LANE_RUNNER}.` + ).toEqual([WINDOWS_LANE_JOB]) + }) + + it('parses a plausible Windows lane invocation', () => { + expect(laneFiles.length).toBeGreaterThan(15) + const missingFromDisk = laneFiles.filter((path) => { + try { + return !statSync(join(projectDir, path)).isFile() + } catch { + return true + } + }) + expect( + missingFromDisk, + 'The Windows lane invokes vitest on paths that do not exist -- vitest will run nothing for them.' + ).toEqual([]) + }) + + it('rediscovers Windows-gated files that are already registered', () => { + // Both discovery paths, proven against real files rather than fixtures: one + // found by filename plus ternary alias, one found only by its gate + // expression because its name says nothing about Windows gating. + expect(gatedFiles).toContain('src/shared/child-process/windows-command-line.win32.test.ts') + expect(gatedFiles).toContain('src/main/agent-hooks/windows-hook-payload-delivery.test.ts') + // And a compound gate, the case this guard was blind to at first. + expect(gatedFiles).toContain('src/main/git/runner-wsl-linked-gitdir-windows.test.ts') + }) + + it('exempts itself, and nothing else, from the scan', () => { + expect(scannedTestFiles.map(({ relativePath }) => relativePath)).toContain(SCANNER_SELF_PATH) + // The exemption is load-bearing only while the fixtures below still match. + expect(isWindows32GatedTestFile(SCANNER_SELF_PATH, sourceOf(SCANNER_SELF_PATH))).toBe(true) + expect(gatedFiles).not.toContain(SCANNER_SELF_PATH) + // The other half of the claim: no sibling rides the exemption. + expect(isScannerSelfPath('config/scripts/pr-code-change-scope.test.mjs')).toBe(false) + }) + + it('holds the Windows-gated population at or above the floor', () => { + // Bounding by the grandfathered lists' lengths would be trivially true -- + // they move together. The floor is a literal for that reason. + expect( + gatedFiles.length, + `Found ${gatedFiles.length} Windows-gated test files; the floor is ${GATED_FILE_FLOOR}. ` + + 'A drop means the scan stopped matching, not that the files went away. Lower the floor ' + + 'only for a genuine deletion.' + ).toBeGreaterThanOrEqual(GATED_FILE_FLOOR) + }) + + it('confirms the classifier distinguishes registered from unregistered paths', () => { + // Without this, a classifier that answered true for everything would make + // the registration assertion below pass for free. + expect(isInClassifier('src/main/windows/windows-pty-job.win32.test.ts')).toBe(true) + expect(isInClassifier('src/main/windows/not-a-real-file.win32.test.ts')).toBe(false) + }) + + it('has every Windows-gated test file in both registration lists', () => { + const grandfathered = new Set([...UNREGISTERED_ON_MAIN, ...MANUAL_OPT_IN]) + const failures = gatedFiles + .filter((path) => !grandfathered.has(path)) + .map(registrationFailure) + .filter((failure) => failure !== null) + expect( + failures, + 'A Windows-gated test file is missing from a Windows CI registration list. It self-skips on ' + + 'ubuntu and reports success, so it runs on no machine. Both lists are required: ' + + 'WINDOWS_PACKAGE_TESTS decides whether the package_windows job runs for a diff, the ' + + 'workflow argv decides whether the file runs once it started. Fix each line below.' + ).toEqual([]) + }) + + it('has no stale entry in either grandfathered list', () => { + const stale = [...UNREGISTERED_ON_MAIN, ...MANUAL_OPT_IN].filter( + (path) => !gatedFiles.includes(path) || registrationFailure(path) === null + ) + expect( + stale, + 'These files are no longer unregistered Windows-gated debt -- they were registered, ' + + 'renamed, un-gated, or deleted. Delete each line from UNREGISTERED_ON_MAIN or ' + + 'MANUAL_OPT_IN; the lists only ever shrink.' + ).toEqual([]) + }) + + it('caps growth of both grandfathered lists', () => { + expect( + UNREGISTERED_ON_MAIN.length, + 'Never raise UNREGISTERED_MAX. Register the file instead.' + ).toBeLessThanOrEqual(UNREGISTERED_MAX) + expect( + MANUAL_OPT_IN.length, + 'Never raise MANUAL_OPT_IN_MAX to avoid registering a file that CI could actually run.' + ).toBeLessThanOrEqual(MANUAL_OPT_IN_MAX) + }) + + it('keeps MANUAL_OPT_IN to suites CI genuinely cannot run', () => { + // Otherwise this list is just a quieter way to skip registration. + const notActuallyOptIn = MANUAL_OPT_IN.filter((path) => !requiresEnvOptIn(sourceOf(path))) + expect( + notActuallyOptIn, + 'A MANUAL_OPT_IN entry has no env-var opt-in, so registering it WOULD make it run. ' + + 'Register it in both lists and delete the line.' + ).toEqual([]) + }) + + it('keeps every UNREGISTERED_ON_MAIN file ineligible for MANUAL_OPT_IN', () => { + // The two lists must not be interchangeable: debt that CI could run must + // not be re-labelled as manual to make the debt cap look better. + const movable = UNREGISTERED_ON_MAIN.filter((path) => requiresEnvOptIn(sourceOf(path))) + expect( + movable, + 'This file is registrable; it cannot be reclassified as MANUAL_OPT_IN.' + ).toEqual([]) + }) +}) + +describe('manual opt-in classification', () => { + it('requires the env read to reach the gate', () => { + // The parking attack: a compound gate CI could satisfy, in a file that + // happens to read an unrelated env var. This is the native-addon-bytes + // shape, and it must read as registrable. + expect( + requiresEnvOptIn( + "const tmp = process.env.RUNNER_TEMP\ndescribe.runIf(process.platform === 'win32' && hasAddon)('x', () => {})" + ) + ).toBe(false) + // One hop through a const: the real shape of the ten listed suites. + expect( + requiresEnvOptIn( + "const distro = process.env.ORCA_TEST_WSL_DISTRO\ndescribe.runIf(process.platform === 'win32' && Boolean(distro))('x', () => {})" + ) + ).toBe(true) + // Read inline in the conjunct: the other real shape. + expect( + requiresEnvOptIn( + "const RUN = process.platform === 'win32' && process.env.ORCA_REAL_X === '1'" + ) + ).toBe(true) + }) + + it('requires the gate to be compound at all', () => { + // A bare `runIf(win32)` file -- which CI can run -- must never park as + // manual, however much `process.env` the file reads elsewhere. + expect( + requiresEnvOptIn( + "const t = process.env.CI\ndescribe.runIf(process.platform === 'win32')('x', () => {})" + ) + ).toBe(false) + // The case that makes the `&&` in WIN32_CONJUNCT load-bearing rather than + // decorative: an env read on the SAME line as a bare gate. Drop the `&&` + // and this reads as manual, which is the parking hole reopened. + expect( + requiresEnvOptIn( + "describe.runIf(process.platform === 'win32')(`x ${process.env.ORCA_TAG}`, () => {})" + ) + ).toBe(false) + }) +}) + +describe('Windows runner detection', () => { + it('reads every runs-on spelling that could land on Windows', () => { + expect(couldRunOnWindows('windows-2022')).toBe(true) + // The spelling that would have slipped past an equality test. + expect(couldRunOnWindows('windows-latest')).toBe(true) + expect(couldRunOnWindows(['self-hosted', 'Windows', 'X64'])).toBe(true) + expect(couldRunOnWindows({ group: 'windows-runners', labels: ['x64'] })).toBe(true) + // Unresolvable from the file, so it fails closed rather than reading as safe. + expect(couldRunOnWindows('${{ matrix.os }}')).toBe(true) + expect(couldRunOnWindows('ubuntu-latest')).toBe(false) + expect(couldRunOnWindows(['self-hosted', 'linux'])).toBe(false) + expect(couldRunOnWindows(undefined)).toBe(false) + }) +}) + +describe('Windows-gate detection', () => { + // Each positive is paired with the near-miss it must reject. The pairs are + // written from the shapes that exist in the repo, not from the regexes above. + const cases = [ + [ + 'describe.runIf equality', + "describe.runIf(process.platform === 'win32')('x', () => {})", + "describe.runIf(process.platform !== 'win32')('x', () => {})" + ], + [ + 'describe.skipIf inequality', + "describe.skipIf(process.platform !== 'win32')('x', () => {})", + "describe.skipIf(process.platform === 'win32')('x', () => {})" + ], + [ + 'ternary describe alias', + "const d = process.platform === 'win32' ? describe : describe.skip", + "const d = process.platform === 'win32' ? describe.skip : describe" + ], + [ + 'inverted ternary describe alias', + "const d = process.platform !== 'win32' ? describe.skip : describe", + "const d = process.platform !== 'win32' ? describe : describe.skip" + ], + [ + 'local isWindows flag', + "const isWindows = process.platform === 'win32'\ndescribe.skipIf(!isWindows)('x', () => {})", + "const isWindows = process.platform === 'win32'\ndescribe.skipIf(isWindows)('x', () => {})" + ], + [ + 'local isWindows flag, runIf', + "const isWindows = process.platform === 'win32'\ndescribe.runIf(isWindows)('x', () => {})", + "const isWindows = process.platform === 'win32'\ndescribe.runIf(!isWindows)('x', () => {})" + ], + [ + // The blocking miss: a second conjunct made the gate invisible. + 'compound gate with a second conjunct', + "describe.runIf(process.platform === 'win32' && Boolean(distro))('x', () => {})", + "describe.runIf(process.platform === 'win32' || Boolean(distro))('x', () => {})" + ], + [ + 'compound gate behind a named flag assigned on the next line', + "const RUN_REAL =\n process.platform === 'win32' && process.env.X === '1'\ndescribe.runIf(RUN_REAL)('x', () => {})", + "const RUN_REAL =\n process.platform !== 'win32' && process.env.X === '1'\ndescribe.runIf(RUN_REAL)('x', () => {})" + ], + [ + 'named flag driving a ternary suite alias', + "const enabled = process.platform === 'win32' && process.env.X === '1'\nconst d = enabled ? describe : describe.skip", + "const enabled = process.platform === 'win32' && process.env.X === '1'\nconst d = enabled ? describe.skip : describe" + ], + [ + 'compound skipIf widened with ||', + "describe.skipIf(process.platform !== 'win32' || !hasAddon)('x', () => {})", + "describe.skipIf(process.platform !== 'win32' && !hasAddon)('x', () => {})" + ], + [ + 'double-quoted and loosely spaced', + 'describe . runIf ( process.platform === "win32" )("x", () => {})', + 'describe . runIf ( process.platform === "darwin" )("x", () => {})' + ] + ] + + for (const [label, gated, nearMiss] of cases) { + it(`detects ${label} and rejects its near miss`, () => { + expect(isWindows32GatedTestFile('src/x/sample.test.ts', gated)).toBe(true) + expect(isWindows32GatedTestFile('src/x/sample.test.ts', nearMiss)).toBe(false) + }) + } + + it('detects the .win32 filename with no gate expression at all', () => { + expect(isWindows32GatedTestFile('src/x/sample.win32.test.ts', 'describe("x", () => {})')).toBe( + true + ) + // Near miss: `.win32.ts` is production source, not a test the lane can run. + expect(isWindows32GatedTestFile('src/x/sample.win32.ts', 'export const x = 1')).toBe(false) + }) + + it('does not read a flag whose name merely starts the same', () => { + // Without word boundaries `isWindows` would swallow `isWindowsHost`. + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "const isWindows = process.platform === 'win32'\ndescribe.runIf(isWindowsHost)('x', () => {})" + ) + ).toBe(false) + }) + + it('rejects the documented blind spots rather than half-detecting them', () => { + // it-level gate inside a cross-platform suite: out of scope by design. + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "describe('x', () => { it.skipIf(process.platform !== 'win32')('y', () => {}) })" + ) + ).toBe(false) + // A platform branch inside a test body is not a gate. + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "it('x', () => { if (process.platform === 'win32') { return } })" + ) + ).toBe(false) + // An imported flag: the assignment is not in this file, so polarity is unknowable. + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "import { isWindows } from './f'\ndescribe.runIf(isWindows)('x', () => {})" + ) + ).toBe(false) + }) + + it('does not treat a widening conjunct behind a named flag as Windows-only', () => { + // `!== 'win32' && x` skips only when BOTH hold, so the suite runs on + // Windows and on POSIX when `x` is false. The literal form is rejected by + // the `||` pair above; this is the same condition routed through a flag, + // which is where the shared lookahead used to flip the answer. + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "const p = process.platform !== 'win32' && Boolean(x)\ndescribe.skipIf(p)('x', () => {})" + ) + ).toBe(false) + // The narrowing direction still counts: `=== 'win32' && x` is Windows-only. + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "const p = process.platform === 'win32' && Boolean(x)\ndescribe.runIf(p)('x', () => {})" + ) + ).toBe(true) + }) + + it('ignores a gate that only appears in prose', () => { + expect( + isWindows32GatedTestFile( + 'src/x/sample.test.ts', + "// describe.runIf(process.platform === 'win32')\ndescribe('x', () => {})" + ) + ).toBe(false) + }) +}) diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs new file mode 100644 index 00000000000..a8c2cb3f4e7 --- /dev/null +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -0,0 +1,129 @@ +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the one idiom that keeps re-killing Windows tooling. + * + * Node >= 20 refuses to spawn a Windows batch shim without `shell: true` (the + * CVE-2024-27980 mitigation), so `spawnSync('pnpm.cmd', …)` throws EINVAL + * before the command runs at all. On Windows that reads as a broken toolchain + * rather than a failing check, so the failure gets shrugged off — which is + * exactly how `check:code-quality:changed` ran dead for months. + * + * `src/` has its own chokepoint (runProcess) and its own ratchet. These trees + * are plain `.mjs` run by bare `node`, outside that module boundary, so they + * need this narrower one: a batch-shim command literal may not appear in a new + * script. The list only shrinks. Resolve the real executable instead — + * `oxlint-cli-invocation.mjs` and `windows-process-tree-gyp-rebuild.mjs` show + * the shape. + * + * Deliberately a text match on any `.cmd`/`.bat` literal, not on a list of + * runner names: these trees already spawn vitest, playwright, electron-builder + * and tsc, and the next offender is as likely to be one of those as it is to be + * pnpm. A literal is all a copy-paste carries. + * + * Two shapes this does not catch, both accepted. A shim assembled in a template + * literal, and a drive-lettered path — 'C:\tools\pnpm.cmd' — since a colon is + * not in the class. Real code builds those with path.join, whose 'pnpm.cmd' + * argument is caught. Also note codeText only drops lines that BEGIN with a + * comment marker, so a trailing `// 'pnpm.cmd'` false-positives; that fails + * closed. All of which is the ceiling of a text ratchet, and the reason `src/` + * gets a real chokepoint instead. + */ +const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i + +const SCANNED_ROOTS = ['config/scripts', 'tests/tools'] + +/** Scripts that still name a batch shim, held as data so it reads as the list it is. */ +const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ + // Owns the pnpm invocation decision for every other script. + 'config/scripts/pnpm-cli-invocation.mjs', + 'config/scripts/pnpm-cli-invocation.test.mjs', + // Write or assert on shim files rather than spawning one. + 'config/scripts/dev-cli-terminal-wrapper.mjs', + 'config/scripts/dev-cli-terminal-wrapper.test.mjs', + 'config/scripts/electron-builder-config.test.mjs', + 'config/scripts/ensure-native-runtime.test.mjs', + 'config/scripts/live-remote-freeze-rpc.mjs', + 'config/scripts/remote-agent-session-authority-repro.mjs', + // Platform-local build paths; the win32 branch is dead code on both. + 'config/scripts/build-mac-local.mjs', + 'config/scripts/build-linux-local.mjs', + 'config/scripts/build-linux-local.test.mjs', + // Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI. + 'config/scripts/build-orcad-prebuilds.mjs', + 'config/scripts/run-ai-vault-typing-bench.mjs', + 'config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs', + 'config/scripts/run-local-ssh-browser-routing-e2e.mjs', + 'config/scripts/run-multi-client-navigation-e2e.mjs', + 'config/scripts/run-multi-workspace-typing-bench.mjs', + 'config/scripts/run-nested-runtime-ssh-e2e.mjs', + 'config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs', + 'config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs', + 'config/scripts/run-ssh-docker-e2e.mjs', + 'config/scripts/run-ssh-docker-perf-e2e.mjs', + 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs', + 'config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs', + 'config/scripts/run-ssh-staged-upload-reliability.mjs', + 'config/scripts/run-terminal-ibus-hangul-e2e.mjs', + 'config/scripts/run-terminal-scale-perf-e2e.mjs', + // Routes its shim through an explicit `cmd.exe /d /s /c`, which is the correct form. + 'config/scripts/verify-skill-update-roundtrip.mjs', + 'tests/tools/benchmarks/startup-time-bench.mjs', + 'tests/tools/benchmarks/worktree-deletion-dev-bench.mjs', + 'tests/tools/repro-terminal-send-submit.mjs' +] + +/** Drop comment-only lines so prose about the old idiom is not an offender. */ +function codeText(contents) { + return contents + .split('\n') + .filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line)) + .join('\n') +} + +// Why recursive: a future config/scripts// would otherwise escape silently. +function collectScripts(directory, repoRoot, found = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') { + collectScripts(full, repoRoot, found) + } + continue + } + if (/\.[cm]?js$/.test(entry.name)) { + found.push(path.relative(repoRoot, full).split(path.sep).join('/')) + } + } + return found +} + +describe('windows batch shim spawn boundary', () => { + const repoRoot = path.resolve(import.meta.dirname, '..', '..') + const scripts = SCANNED_ROOTS.flatMap((root) => + collectScripts(path.join(repoRoot, root), repoRoot) + ) + const offenders = scripts.filter((relativePath) => + WINDOWS_SHIM_LITERAL.test(codeText(readFileSync(path.join(repoRoot, relativePath), 'utf8'))) + ) + + it('scans a plausible number of scripts', () => { + // A broken root or extension filter would make the guard silently vacuous. + expect(scripts.length).toBeGreaterThan(100) + }) + + it('has no unlisted script naming a Windows batch shim', () => { + const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name)) + expect( + unlisted, + 'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxlint-cli-invocation.mjs.' + ).toEqual([]) + }) + + it('has no stale allowlist entry', () => { + const stale = WINDOWS_SHIM_SPAWN_ALLOWLIST.filter((name) => !offenders.includes(name)) + expect(stale, 'Script no longer names a batch shim — delete the line.').toEqual([]) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 93d556602f8..1b9600188f2 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -127,6 +127,8 @@ // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this // project's serve spec; the module has no imports, so listing it pulls in nothing else. "../src/main/startup/serve-mode-argv.ts", + // The parity test keeps this import-free list aligned with COMMAND_SPECS. + "../src/main/startup/cli-command-names.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/sqlite/sync-database.ts", "../src/main/win32-utils.ts" diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index afe5e83024c..56253527c69 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -31,6 +31,7 @@ "../src/main/wsl-distro-retry.ts", "../src/main/wsl-running-distro-cache.ts", "../src/main/wsl.ts", + "../src/main/wsl-interop-spawn-directory.ts", "../src/main/persistence/applying-settings/ui-state-read.ts", "../src/main/persistence/applying-settings/ui-state-update.ts", "../src/main/persistence/applying-settings/ui-selection-normalization.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 0bde5e2704a..0708d09d993 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 35m + + downloads: 36m @@ -15,7 +15,7 @@ downloads downloads - 35m - 35m + 36m + 36m diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png index f2695951e92..f069eb9059c 100644 Binary files a/docs/assets/star-history.png and b/docs/assets/star-history.png differ diff --git a/docs/assets/wechat-qr-group7.jpg b/docs/assets/wechat-qr-group7.jpg deleted file mode 100644 index c56f76c9a1c..00000000000 Binary files a/docs/assets/wechat-qr-group7.jpg and /dev/null differ diff --git a/docs/assets/wechat-qr-group8.jpg b/docs/assets/wechat-qr-group8.jpg index 540b751820a..07b50f78b83 100644 Binary files a/docs/assets/wechat-qr-group8.jpg and b/docs/assets/wechat-qr-group8.jpg differ diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index c9bb161fe8e..a64b1af58af 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -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. - QR code WeChat groupe 7 de la communauté Orca + QR code WeChat groupe 8 de la communauté Orca - **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues). - **Confidentialité :** Voir la [doc confidentialité & 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. diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 1de67052fcb..01c8cd71ce1 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -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에 참여하세요. - Orca 커뮤니티 WeChat 그룹 7 QR 코드 + Orca 커뮤니티 WeChat 그룹 8 QR 코드 - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 2e11413aa2a..d3ace8a6af4 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -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 群。 - Orca 社区微信第 7 群二维码   Orca 社区微信第 8 群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index 0e8b1f257d3..1e19860385e 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -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` diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 3d7db834e8e..50a38cf446e 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -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 diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index a11506235fe..20e9b38acb5 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -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 diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index bbde9829514..2901a5bf0b6 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -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://:` | `/daemon/daemon-v.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. diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md index d24cdc38e8e..45b307411f6 100644 --- a/docs/reference/ssh-execution-boundary.md +++ b/docs/reference/ssh-execution-boundary.md @@ -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.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. diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md new file mode 100644 index 00000000000..06eb2d5ff9b --- /dev/null +++ b/docs/reference/windows-edr-posture.md @@ -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\\` 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 `, 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 `` segment, so a rule pinned +to one version will silently stop matching. Two traps in that path in particular. +Materialization stages into a `.staging-` 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. diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index caa8bed8600..d278c90983a 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -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 diff --git a/docs/reference/wsl-probe-failure-semantics.md b/docs/reference/wsl-probe-failure-semantics.md index cadcbf53364..dcaa67a5d4d 100644 --- a/docs/reference/wsl-probe-failure-semantics.md +++ b/docs/reference/wsl-probe-failure-semantics.md @@ -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 diff --git a/docs/site/content/docs/cli/overview.mdx b/docs/site/content/docs/cli/overview.mdx index 3248e89e1eb..1e966c6217c 100644 --- a/docs/site/content/docs/cli/overview.mdx +++ b/docs/site/content/docs/cli/overview.mdx @@ -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: diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 3df0773a4a7..a13ec0fdde4 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -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. + + + 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). + + +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 diff --git a/docs/site/content/docs/install.mdx b/docs/site/content/docs/install.mdx index f710644d19e..9341cb0fa0d 100644 --- a/docs/site/content/docs/install.mdx +++ b/docs/site/content/docs/install.mdx @@ -31,8 +31,11 @@ import { Callout } from '@/components/docs/prose'
  • **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
  • Older versions: [GitHub Releases](https://github.com/stablyai/orca/releases).
  • @@ -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__amd64.deb` or `_arm64.deb`; `orca-ide-.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. + + + [#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. + + +#### 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 +``` diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 63f94e35af8..37f86665d6e 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -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: + + 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). + + ```bash orca serve --pairing-address ``` diff --git a/docs/site/content/docs/review/annotate-ai-diff.mdx b/docs/site/content/docs/review/annotate-ai-diff.mdx index 6ac3fe4e6a1..0744b2c5a9f 100644 --- a/docs/site/content/docs/review/annotate-ai-diff.mdx +++ b/docs/site/content/docs/review/annotate-ai-diff.mdx @@ -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. - + ## Leave a comment diff --git a/docs/site/content/docs/ways-to-run.mdx b/docs/site/content/docs/ways-to-run.mdx index 1a9c44ba1d1..e35b63eae90 100644 --- a/docs/site/content/docs/ways-to-run.mdx +++ b/docs/site/content/docs/ways-to-run.mdx @@ -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 +orca-ide serve --pairing-address ``` Full detail: [Remote Orca Servers](/docs/remote-servers). diff --git a/package.json b/package.json index aab4c660c48..9846604fab6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a2036d6596..fa22a5f8b32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/resources/linux/bin/orca-ide b/resources/linux/bin/orca-ide index 88b04e9e47d..f191f27c770 100755 --- a/resources/linux/bin/orca-ide +++ b/resources/linux/bin/orca-ide @@ -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" "$@" diff --git a/resources/linux/packaging/after-remove.sh b/resources/linux/packaging/after-remove.sh index 0f497024613..a23426df446 100755 --- a/resources/linux/packaging/after-remove.sh +++ b/resources/linux/packaging/after-remove.sh @@ -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 diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index eeedfbe0428..1ac86d99e12 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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']) diff --git a/src/cli/args.ts b/src/cli/args.ts index c4c373a814f..a934915655e 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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, 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() + 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 } diff --git a/src/cli/cli-command-name-parity.test.ts b/src/cli/cli-command-name-parity.test.ts new file mode 100644 index 00000000000..32bbcc06add --- /dev/null +++ b/src/cli/cli-command-name-parity.test.ts @@ -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(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()) + }) +}) diff --git a/src/cli/cli-version.test.ts b/src/cli/cli-version.test.ts new file mode 100644 index 00000000000..6ffe60692fb --- /dev/null +++ b/src/cli/cli-version.test.ts @@ -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() + }) +}) diff --git a/src/cli/cli-version.ts b/src/cli/cli-version.ts new file mode 100644 index 00000000000..0918ec763fd --- /dev/null +++ b/src/cli/cli-version.ts @@ -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 + } +} diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index db481de6ea8..6bc6d6eee0b 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -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 diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 145540bb627..d4979ff2ae9 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -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 { // Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI @@ -92,43 +93,29 @@ export const CORE_HANDLERS: Record = { 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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 2a8921a2cdb..c29bfff7060 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 { + // 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 diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 235b2b2ed56..7931e489e3d 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -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') + } + } + }) }) diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index bd7d939be5a..a326ae333f5 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -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 { 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): Promise { diff --git a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts index f5d348798c3..cc47deec3f7 100644 --- a/src/cli/runtime/serve-signal-exit-diagnostic.test.ts +++ b/src/cli/runtime/serve-signal-exit-diagnostic.test.ts @@ -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-')) diff --git a/src/cli/runtime/serve-update-supervisor.ts b/src/cli/runtime/serve-update-supervisor.ts index a5a303dc1a2..f791ef2ae6e 100644 --- a/src/cli/runtime/serve-update-supervisor.ts +++ b/src/cli/runtime/serve-update-supervisor.ts @@ -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() let forceKillTimer: ReturnType | null = null let readyTimer: ReturnType | 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 diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 4213d360a41..a4964e1a824 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -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}'`) diff --git a/src/main/agent-hooks/server-replay-evidence-clock.test.ts b/src/main/agent-hooks/server-replay-evidence-clock.test.ts new file mode 100644 index 00000000000..12475a35d64 --- /dev/null +++ b/src/main/agent-hooks/server-replay-evidence-clock.test.ts @@ -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, + 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) + }) +}) diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts index 9f977672239..b6633dbf6f1 100644 --- a/src/main/agent-hooks/server-status-listener-fanout.test.ts +++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts @@ -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() diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 04acc058dea..4fcd0b5e58e 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 956be136ca6..7dc8125576e 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -97,6 +97,10 @@ export abstract class AgentHookServerState { protected closedAgentStatusPaneKeys = new Set() protected restartedStatusLaunchTokenHashByPaneKey = new Map() protected connectionTimestampWatermarkById = new Map() + // 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() // 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 diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 7fbb6a96bc1..f7e1126d11b 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -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) diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 4abacfc81d0..3ce2c4fce0a 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -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 } } diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 913bcd7067e..c151c70d34b 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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 diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.test.ts b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts new file mode 100644 index 00000000000..6ed9cf2625a --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-launch.test.ts @@ -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) }) + ) + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 9f32de10bd5..ae1ea9c20d7 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -16,6 +16,7 @@ import { } from './wsl-hook-relay-sentinel' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { runWslProcess } from '../wsl/wsl-runner' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' import { listRunningWslDistrosAsync } from '../wsl' import { WSL_HOOK_RELAY_BUNDLE_NAME, @@ -137,7 +138,11 @@ export function spawnWslRelayProcess( return spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-c', command], { env, stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + // Why explicit (#16463): the guest path is in `command`, so the Windows cwd + // only decides whether CreateProcessW succeeds -- and an inherited one is a + // worktree the user can delete, which kills every later relay launch. + cwd: resolveWslInteropSpawnCwd() }) } diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index fbabc3bf6dd..a3b5e9a0523 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -20,11 +20,9 @@ import { } from './cli-command-filesystem-transaction' import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants' import { buildWindowsForwarder } from './cli-dev-launcher' -import { isMissingError, isPermissionError } from './cli-install-errors' +import { isPermissionError } from './cli-install-errors' import { isPathInsideOrEqual } from './cli-install-path-format' -const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3 - export class CliCommandInstallation extends CliCommandInspection { protected async installSymlink(status: CliInstallStatus): Promise { const commandPath = status.commandPath @@ -198,34 +196,25 @@ export class CliCommandInstallation extends CliCommandInspection { }) | null > { - for (let attempt = 0; attempt < STABLE_LEGACY_INSPECTION_ATTEMPTS; attempt += 1) { - const before = await readEntrySnapshot(commandPath) - if (!before) { - return null - } - let target: string | null = null - try { - target = before.isSymbolicLink ? await readlink(commandPath) : null - } catch (error) { - if (isMissingError(error)) { - continue - } - throw error - } - const after = await readEntrySnapshot(commandPath) - if (after && hasSameSnapshot(before, after)) { - const resolvedTarget = target ? resolve(dirname(commandPath), target) : null - return { - fileSha256: null, - rawSymlinkTarget: target, - snapshot: after, - managed: Boolean( - resolvedTarget && this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) - ) - } - } + const inspected = await inspectStableCommand(commandPath, () => + this.inspectSymlink(commandPath, launcherPath) + ) + if (!inspected.snapshot) { + return null + } + const resolvedTarget = inspected.rawSymlinkTarget + ? resolve(dirname(commandPath), inspected.rawSymlinkTarget) + : inspected.status.currentTarget + return { + fileSha256: inspected.fileSha256, + rawSymlinkTarget: inspected.rawSymlinkTarget, + snapshot: inspected.snapshot, + managed: Boolean( + resolvedTarget && + (this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) || + (this.appImagePath && resolve(resolvedTarget) === resolve(this.appImagePath))) + ) } - throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) } private async restoreQuarantinedCommand( diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 1a5279644e9..51d5cf05e35 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -370,6 +370,56 @@ describe('CliInstaller', () => { } ) + it.skipIf(process.platform === 'win32')( + 'removes a legacy AppImage wrapper only when it names the current AppImage', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const legacyCommandPath = join(commandDir, 'orca') + const appImagePath = join(fixture.root, 'Orca.AppImage') + const foreignAppImagePath = join(fixture.root, 'Other.AppImage') + const cacheRootPath = join(fixture.root, 'cache') + await mkdir(commandDir, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(foreignAppImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, + homePath, + processPathEnv: commandDir + }) + + await installer.install() + await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + + await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(foreignAppImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await installer.remove() + await expect(readFile(legacyCommandPath, 'utf8')).resolves.toBe( + buildLegacyAppImageCliWrapper(foreignAppImagePath) + ) + } + ) + // Why: the privilegedRunner is injectable so the EACCES→osascript path can be // exercised in integration without spawning osascript in unit tests. it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index d5c17123ec1..1fb71e3e00f 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -305,6 +305,63 @@ node -e 'console.log(JSON.stringify({ await rm(root, { recursive: true, force: true }) } }) + + itRunsUnixShell('keeps Linux serve on the CLI entrypoint in node mode', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-serve-')) + try { + const appDir = join(root, 'Orca') + const resourcesDir = join(appDir, 'resources') + const launcherDir = join(resourcesDir, 'bin') + const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + const launcherPath = join(launcherDir, 'orca-ide') + const appRunPath = join(appDir, 'AppRun') + const electronPath = join(appDir, 'orca-ide') + const cliPath = join(cliDir, 'index.js') + const statePath = join(root, 'launch-state.json') + + await mkdir(launcherDir, { recursive: true }) + await mkdir(cliDir, { recursive: true }) + await copyFile(linuxLauncherAsset, launcherPath) + await writeFile(cliPath, '', 'utf8') + // An accidental AppRun handoff would skip CLI validation and fail this contract. + await writeFile( + appRunPath, + `#!/usr/bin/env bash +printf 'unexpected AppRun handoff\n' >&2 +exit 97 +`, + { encoding: 'utf8', mode: 0o755 } + ) + await writeFile( + electronPath, + `#!/usr/bin/env node +require('node:fs').writeFileSync(process.env.ORCA_TEST_LAUNCH_STATE, JSON.stringify({ + argv: process.argv.slice(2), + runAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null +})) +`, + { encoding: 'utf8', mode: 0o755 } + ) + + await execFileAsync(launcherPath, ['serve', '--recipe-json', '--project-root', '/tmp/repo'], { + env: { ...process.env, ORCA_TEST_LAUNCH_STATE: statePath } + }) + const payload = JSON.parse(await readFile(statePath, 'utf8')) as { + argv: string[] + runAsNode: string | null + } + expect(payload.argv).toEqual([ + cliPath, + 'serve', + '--recipe-json', + '--project-root', + '/tmp/repo' + ]) + expect(payload.runAsNode).toBe('1') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) async function waitForListenerState(path: string): Promise<{ pid: number; port: number }> { diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 717232509ba..a728e0acafe 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -340,7 +340,13 @@ describe('WslCliInstaller', () => { expect(bridge).toContain('$ForwardArgs = @($args[$ForwardArgStart..($args.Count - 1)])') expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') - expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') + expect(bridge).toContain('$LauncherDirectory = Split-Path -Parent $OrcaLauncher') + expect(bridge).toContain('Push-Location -LiteralPath $LauncherDirectory') + // Why (#16463): Push-Location moves only the PowerShell provider location. + // Without an explicit WorkingDirectory the started app inherits the caller's + // Win32 cwd — the user's worktree on \\wsl.localhost — and every wsl.exe + // spawn it makes dies with ENOENT once that worktree is removed. + expect(bridge).toContain('$StartInfo.WorkingDirectory = $LauncherDirectory') expect(bridge).toContain('function ConvertTo-NativeCommandLineArgument') expect(bridge).toContain("[void]$Quoted.Append([char]'\\', $BackslashCount * 2 + 1)") expect(bridge).toContain('$StartInfo.UseShellExecute = $false') diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 8eda355cc93..8875a81d18e 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -88,7 +88,8 @@ try { } else { $env:ORCA_CLI_CWD = $WslCwd } - Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) + $LauncherDirectory = Split-Path -Parent $OrcaLauncher + Push-Location -LiteralPath $LauncherDirectory # Why: Windows PowerShell 5.1 cannot losslessly splat strings to native argv. $StartInfo = [System.Diagnostics.ProcessStartInfo]::new() $StartInfo.FileName = $OrcaLauncher @@ -96,6 +97,13 @@ try { ConvertTo-NativeCommandLineArgument $_ }) -join ' ') $StartInfo.UseShellExecute = $false + # Why (#16463): Push-Location moves the PowerShell provider location, not the + # Win32 current directory, and an empty WorkingDirectory with UseShellExecute + # disabled means "inherit the caller's". Launched from a WSL shell that is the + # user's worktree on the 9P share, so without this the app stands in a + # directory Linux can delete -- after which every CreateProcessW it makes + # fails ERROR_PATH_NOT_FOUND, reported as: spawn wsl.exe ENOENT. + $StartInfo.WorkingDirectory = $LauncherDirectory $Process = [System.Diagnostics.Process]::Start($StartInfo) if ($null -eq $Process) { throw 'Unable to start the Orca Windows CLI launcher.' diff --git a/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts b/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts index 6cad595f866..2872ecf15c3 100644 --- a/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts +++ b/src/main/codex-accounts/runtime-home-settings-test-fixtures.ts @@ -130,6 +130,7 @@ export function createSettings(overrides: TestSettingsOverrides = {}): GlobalSet terminalWindowsPowerShellImplementation: 'powershell.exe', ...overrides, diffWordWrap: overrides.diffWordWrap ?? false, + diffShowWhitespace: overrides.diffShowWhitespace ?? false, localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }, leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default', appFontFamily, diff --git a/src/main/codex-accounts/service-test-harness.ts b/src/main/codex-accounts/service-test-harness.ts index 9afd6a8ba5e..ed454c7a149 100644 --- a/src/main/codex-accounts/service-test-harness.ts +++ b/src/main/codex-accounts/service-test-harness.ts @@ -151,6 +151,7 @@ export function createSettings(overrides: Partial = {}): GlobalS terminalWindowsPowerShellImplementation: 'powershell.exe', ...overrides, diffWordWrap: overrides.diffWordWrap ?? false, + diffShowWhitespace: overrides.diffShowWhitespace ?? false, localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }, leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default', appFontFamily, diff --git a/src/main/daemon/node-pty-fd-leak.test.ts b/src/main/daemon/node-pty-fd-leak.test.ts index 91958b49975..f021f7d48df 100644 --- a/src/main/daemon/node-pty-fd-leak.test.ts +++ b/src/main/daemon/node-pty-fd-leak.test.ts @@ -1,5 +1,6 @@ -import { execFileSync } from 'node:child_process' -import { existsSync, renameSync } from 'node:fs' +import { execFileSync, spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, readdirSync, readFileSync, readlinkSync, renameSync } from 'node:fs' import { setTimeout as delay } from 'node:timers/promises' import * as pty from 'node-pty' import { describe, expect, it } from 'vitest' @@ -90,3 +91,105 @@ describeOnDarwin('node-pty macOS spawn fd handling', () => { expect(after - before).toBe(0) }, 15000) }) + +// Linux is the only platform where node-pty takes the forkpty() path, which has no atomic +// O_CLOEXEC. /proc is what makes the inheritance observable, so the assertions live here. +const describeOnLinux = process.platform === 'linux' ? describe : describe.skip + +const O_CLOEXEC = 0o2000000 + +const LISTING_READY = '__fd_listing_ready__' + +function ptyMasterFd(term: pty.IPty): number { + return (term as unknown as { fd: number }).fd +} + +function isCloseOnExec(fd: number): boolean { + const flags = /flags:\s*(\d+)/.exec(readFileSync(`/proc/self/fdinfo/${fd}`, 'utf8')) + expect(flags).toBeTruthy() + return (Number.parseInt(flags![1]!, 8) & O_CLOEXEC) !== 0 +} + +function openFdTargets(pid: number): string[] { + return readdirSync(`/proc/${pid}/fd`).map((entry) => { + try { + return readlinkSync(`/proc/${pid}/fd/${entry}`) + } catch { + return '' + } + }) +} + +describeOnLinux('node-pty Linux forkpty fd handling', () => { + it('marks pty masters close-on-exec so later children cannot inherit them', async () => { + const terms: pty.IPty[] = [] + let child: ReturnType | null = null + try { + for (let i = 0; i < 3; i++) { + terms.push( + pty.spawn('/bin/sh', ['-c', 'sleep 30'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env, ORCA_FD_LEAK_TEST_INDEX: String(i) } + }) + ) + } + + // The masters this process owns must not survive an exec in any child it forks later. + expect(terms.map((term) => isCloseOnExec(ptyMasterFd(term)))).toEqual([true, true, true]) + + child = spawn('/bin/sh', ['-c', 'sleep 5'], { stdio: 'ignore' }) + await once(child, 'spawn') + const inherited = openFdTargets(child.pid!).filter((target) => target.includes('ptmx')) + expect(inherited).toEqual([]) + } finally { + child?.kill() + for (const term of terms) { + term.kill() + } + } + }, 15000) + + it('does not hand an earlier pty master to a later pty child', async () => { + const first = pty.spawn('/bin/sh', ['-c', 'sleep 30'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env } + }) + try { + // Why the read: the child must not be able to run its listing before onData is armed, or an + // empty capture would satisfy the negative assertion without inspecting a single fd. + const second = pty.spawn( + '/bin/sh', + ['-c', `IFS= read -r _; printf '${LISTING_READY}\\n'; ls -l /proc/self/fd; exit 0`], + { + name: 'xterm-256color', + cols: 200, + rows: 24, + cwd: process.cwd(), + env: { ...process.env } + } + ) + let output = '' + second.onData((data) => { + output += data + }) + second.write('go\n') + await new Promise((resolve) => { + second.onExit(() => resolve()) + }) + await delay(100) + + // The listing is the evidence; assert it arrived before reading anything into its absence. + expect(output).toContain(LISTING_READY) + expect(output).toMatch(/\d+ -> \/dev\/pts\//) + expect(output).not.toMatch(/ptmx/) + } finally { + first.kill() + } + }, 15000) +}) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 547c5227ffb..9dc60b7c980 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -175,6 +175,7 @@ export function getShellLaunchConfig( args: [ '-NoLogo', '-NoExit', + // Why base64 and not -Command: see powershell-osc133-bootstrap.ts (MDE review). '-EncodedCommand', encodePowerShellCommand(getPowerShellOsc133Bootstrap()) ], diff --git a/src/main/daemon/terminal-attach-cancellation.ts b/src/main/daemon/terminal-attach-cancellation.ts new file mode 100644 index 00000000000..9e87cbdb1bc --- /dev/null +++ b/src/main/daemon/terminal-attach-cancellation.ts @@ -0,0 +1,17 @@ +import { TerminalAttachCanceledError } from './daemon-errors' + +/** Never resolves; only rejects, so it can bound a wait without settling it. */ +export function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise { + if (!signal) { + return new Promise(() => {}) + } + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(new TerminalAttachCanceledError(sessionId)) + return + } + signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), { + once: true + }) + }) +} diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 9ee51c9968d..8f6833c3d9f 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -12,11 +12,14 @@ import type { TerminalHostTombstones } from './terminal-host-tombstones' import type { TerminalSessionTeardown } from './terminal-session-teardown' import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window' import { TerminalAttachCanceledError } from './daemon-errors' +import { rejectOnAbort } from './terminal-attach-cancellation' import { SessionNotFoundError } from './types' import { resolveWslSessionContext } from './wsl-session-context' type TerminalHostSessionCreateDependencies = { sessions: Map + /** Re-checks the host's shutdown fence and this request's cancellation after any await. */ + assertCreateAllowed: () => void sessionTeardown: TerminalSessionTeardown killedTombstones: TerminalHostTombstones spawnSubprocess: TerminalHostOptions['spawnSubprocess'] @@ -31,12 +34,29 @@ export async function createOrAttachTerminalSession( deps: TerminalHostSessionCreateDependencies ): Promise { opts.onSessionResolved?.(opts.sessionId) - const existing = deps.sessions.get(opts.sessionId) + let existing = deps.sessions.get(opts.sessionId) // Why: descendant capture must finish before attach or recreation, or the // caller could receive a doomed session while teardown owns its process. if (deps.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) { - throw new SessionNotFoundError(opts.sessionId) + // An attach must not adopt a doomed session; its caller retires the pane and respawns. + if (opts.attachOnly) { + throw new SessionNotFoundError(opts.sessionId) + } + // A create can wait teardown out instead, and must: a pane respawning onto its own stable id + // reaches this a beat after the attach that retired it, and refusing surfaced the raw + // SessionNotFoundError to the user. Windows makes it the common case, where the plain-shell + // sweep holds the claim across an OS identity probe and taskkill (#18046). + await Promise.race([ + deps.sessionTeardown.settle(opts.sessionId), + rejectOnAbort(opts.cancelSignal, opts.sessionId) + ]) + deps.assertCreateAllowed() + existing = deps.sessions.get(opts.sessionId) + // Unkillable child, or a fresh teardown claimed it while we waited: still nobody's to recreate. + if (existing?.isAlive && existing.isTerminating) { + throw new SessionNotFoundError(opts.sessionId) + } } // Why no ownership settle here: attach is synchronous by contract. A viewer diff --git a/src/main/daemon/terminal-host-teardown-recreate.test.ts b/src/main/daemon/terminal-host-teardown-recreate.test.ts new file mode 100644 index 00000000000..7bfb97bdb7d --- /dev/null +++ b/src/main/daemon/terminal-host-teardown-recreate.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' + +// Why mocked: the win32 plain-shell teardown sweeps for real, and an unmocked run would put a +// live process-table probe -- and, on a recycled pid, a taskkill /T /F -- behind these tests. +const killWithDescendantSweepMock = vi.hoisted(() => vi.fn()) +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: killWithDescendantSweepMock +})) + +type SpawnSubprocess = TerminalHostOptions['spawnSubprocess'] +type ExitableSubprocess = SubprocessHandle & { exit: (code: number) => void } + +/** Shells that report their exit only after `exitDelayMs`, holding the teardown claim open the + * way a real one does while the Windows sweep probes and taskkills its tree. Collected so a test + * can retire an intentionally unkillable child instead of leaking its exit waiter. */ +function spawnSubprocessWithSlowExit(exitDelayMs: number): { + spawnSubprocess: Mock + handles: ExitableSubprocess[] +} { + const handles: ExitableSubprocess[] = [] + const spawnSubprocess = vi.fn(() => { + let onExit: ((code: number) => void) | undefined + const handle = { + pid: 4242, + exit: (code: number) => onExit?.(code), + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExit?.(0), exitDelayMs).unref?.() + }), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => { + setTimeout(() => onExit?.(137), exitDelayMs).unref?.() + }), + signal: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((callback) => { + onExit = callback + }), + dispose: vi.fn() + } as unknown as ExitableSubprocess + handles.push(handle) + return handle + }) + return { spawnSubprocess, handles } +} + +const streamClient = (): { onData: Mock; onExit: Mock } => ({ + onData: vi.fn(), + onExit: vi.fn() +}) + +describe('TerminalHost recreate during teardown', () => { + it('recreates a session whose id is still being torn down', async () => { + const { spawnSubprocess } = spawnSubprocessWithSlowExit(40) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@respawning-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + // The pane closes and immediately respawns onto its own stable id (#18046). + const killed = host.kill(sessionId, { immediate: true }) + const recreated = await host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + streamClient: streamClient() + }) + + expect(recreated.isNew).toBe(true) + expect(spawnSubprocess).toHaveBeenCalledTimes(2) + await killed + await host.dispose() + }) + + it('still refuses an attach-only respawn onto a session being torn down', async () => { + const { spawnSubprocess } = spawnSubprocessWithSlowExit(40) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@attaching-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + const killed = host.kill(sessionId, { immediate: true }) + // Why unchanged: adopting a doomed session would hand the pane a shell teardown owns; the + // caller retires the pane binding on this error and spawns fresh. + await expect( + host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + attachOnly: true, + streamClient: streamClient() + }) + ).rejects.toThrow(`Session not found: ${sessionId}`) + expect(spawnSubprocess).toHaveBeenCalledOnce() + await killed + await host.dispose() + }) + + it('refuses a create waiting on teardown once the host is shutting down', async () => { + const { spawnSubprocess } = spawnSubprocessWithSlowExit(40) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@shutting-down-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + const killed = host.kill(sessionId, { immediate: true }) + const create = host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + streamClient: streamClient() + }) + // Why: dispose joins pending creations, so a create that waited out teardown must re-read the + // fence rather than publish a session nothing will shut down. + const disposed = host.dispose() + + await expect(create).rejects.toThrow('Terminal host is shutting down') + expect(spawnSubprocess).toHaveBeenCalledOnce() + await killed + await disposed + }) + + it('leaves a canceled create waiting on teardown instead of the full exit budget', async () => { + // Why a child that never exits on its own: the create must leave on its abort signal, not on + // the teardown settling, so the teardown deliberately outlives the assertion. + const { spawnSubprocess, handles } = spawnSubprocessWithSlowExit(30_000) + const host = new TerminalHost({ spawnSubprocess }) + const sessionId = 'wt-1@@canceled-pane' + await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() }) + + const killed = host.kill(sessionId, { immediate: true }) + const canceled = new AbortController() + const create = host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + cancelSignal: canceled.signal, + isCanceled: () => canceled.signal.aborted, + streamClient: streamClient() + }) + canceled.abort() + + await expect(create).rejects.toThrow(`Attach canceled for session ${sessionId}`) + expect(spawnSubprocess).toHaveBeenCalledOnce() + + handles[0].exit(137) + await killed + await host.dispose() + }) +}) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 8755005b42e..142b9b2c5a2 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -473,14 +473,17 @@ describe('TerminalHost', () => { expect(lastSubprocess.forceKill).toHaveBeenCalledTimes(1) expect(lastSubprocess.dispose).not.toHaveBeenCalled() expect(host.listSessions()).toHaveLength(1) - await expect( - host.createOrAttach({ - sessionId: 'session-1', - cols: 80, - rows: 24, - streamClient: { onData: vi.fn(), onExit: vi.fn() } - }) - ).rejects.toThrow('Session not found') + // An unkillable child never releases the id: the create waits out its own budget and + // then reports absence rather than publishing a session teardown still owns. + const recreate = host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const refused = expect(recreate).rejects.toThrow('Session not found') + await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS) + await refused lastSubprocess._onExitCb?.(137) expect(host.listSessions()).toHaveLength(0) @@ -525,7 +528,7 @@ describe('TerminalHost', () => { expect(lastSubprocess.dispose).toHaveBeenCalled() }) - it('rejects reattach while an agent immediate-kill snapshot is pending', async () => { + it('defers a respawn until the agent immediate-kill snapshot completes', async () => { let finishSweep!: () => void killWithDescendantSweepMock.mockImplementation( (_pid: number, finish: () => void) => @@ -544,22 +547,35 @@ describe('TerminalHost', () => { streamClient: { onData: vi.fn(), onExit: vi.fn() } }) + const retiredSubprocess = lastSubprocess const killing = host.kill('agent-reattach', { immediate: true }) - await expect( - host.createOrAttach({ + let respawned = false + const respawn = host + .createOrAttach({ sessionId: 'agent-reattach', cols: 80, rows: 24, launchAgent: 'claude', streamClient: { onData: vi.fn(), onExit: vi.fn() } }) - ).rejects.toThrow('Session not found') - expect(lastSubprocess.forceKill).not.toHaveBeenCalled() + .then((result) => { + respawned = true + return result + }) + await Promise.resolve() + await Promise.resolve() + + // Why it must not resolve yet: capture still owns the process, so publishing here would + // hand the caller a session teardown is about to kill. + expect(respawned).toBe(false) + expect(spawnFn).toHaveBeenCalledTimes(1) + expect(retiredSubprocess.forceKill).not.toHaveBeenCalled() finishSweep() - lastSubprocess._onExitCb?.(137) + retiredSubprocess._onExitCb?.(137) await killing - expect(lastSubprocess.forceKill).toHaveBeenCalledOnce() + await expect(respawn).resolves.toMatchObject({ isNew: true }) + expect(retiredSubprocess.forceKill).toHaveBeenCalledOnce() }) it('coalesces duplicate immediate kill while descendant capture is pending', async () => { @@ -606,29 +622,31 @@ describe('TerminalHost', () => { const killing = host.kill('agent-natural-exit', { immediate: true }) retiredSubprocess._onExitCb?.(0) - await expect( - host.createOrAttach({ + let respawned = false + const respawn = host + .createOrAttach({ sessionId: 'agent-natural-exit', cols: 80, rows: 24, launchAgent: 'claude', streamClient: { onData: vi.fn(), onExit: vi.fn() } }) - ).rejects.toThrow('Session not found') + .then((result) => { + respawned = true + return result + }) + await Promise.resolve() + await Promise.resolve() + + // The root is already reaped, but the scan still holds the id. + expect(respawned).toBe(false) + expect(spawnFn).toHaveBeenCalledTimes(1) completeSweep() await killing expect(retiredSubprocess.forceKill).not.toHaveBeenCalled() - await expect( - host.createOrAttach({ - sessionId: 'agent-natural-exit', - cols: 80, - rows: 24, - launchAgent: 'claude', - streamClient: { onData: vi.fn(), onExit: vi.fn() } - }) - ).resolves.toEqual(expect.objectContaining({ isNew: true })) + await expect(respawn).resolves.toEqual(expect.objectContaining({ isNew: true })) expect(spawnFn).toHaveBeenCalledTimes(2) }) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index f85980b5453..f64b886f424 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -21,24 +21,10 @@ import { listLiveTerminalHostSessions } from './terminal-host-session-listing' import { createOrAttachTerminalSession } from './terminal-host-session-create' import { isShellProcess } from '../../shared/agent-detection' import { TerminalAttachCanceledError } from './daemon-errors' +import { rejectOnAbort } from './terminal-attach-cancellation' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' -/** Never resolves; only rejects, so it can bound a wait without settling it. */ -function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise { - if (!signal) { - return new Promise(() => {}) - } - return new Promise((_resolve, reject) => { - if (signal.aborted) { - reject(new TerminalAttachCanceledError(sessionId)) - return - } - signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), { - once: true - }) - }) -} export type { TerminalHostOptions } from './terminal-host-options' const DEFAULT_MAX_TOMBSTONES = 1000 @@ -106,6 +92,7 @@ export class TerminalHost { } return await createOrAttachTerminalSession(options, { sessions: this.sessions, + assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options), sessionTeardown: this.sessionTeardown, killedTombstones: this.killedTombstones, spawnSubprocess: this.spawnSubprocess, diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index 5c4f8061247..017f841a5e0 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -1,7 +1,7 @@ import { killWithDescendantSweep } from '../pty-descendant-termination' import type { Session } from './session' -type AgentTeardownOperation = { +type TeardownOperation = { promise: Promise immediate: boolean rootSignalled: boolean @@ -9,10 +9,10 @@ type AgentTeardownOperation = { session: Session } -/** Owns agent teardown by session id until descendant capture and root - * signalling finish, even when the root exits and its Session is reaped. */ +/** Owns teardown by session id until descendant capture and root signalling + * finish, even when the root exits and its Session is reaped. */ export class TerminalSessionTeardown { - private operations = new Map() + private operations = new Map() constructor(private sessions: ReadonlyMap) {} @@ -20,6 +20,12 @@ export class TerminalSessionTeardown { return this.operations.get(sessionId)?.promise } + /** Resolves once this id's tracked teardown has released the process — a rejected teardown + * released it too. Callers re-read session state afterwards and decide for themselves. */ + async settle(sessionId: string): Promise { + await this.operations.get(sessionId)?.promise.catch(() => {}) + } + requestImmediate(sessionId: string): Promise | undefined { const pending = this.operations.get(sessionId) if (pending) { @@ -38,11 +44,42 @@ export class TerminalSessionTeardown { return this.killAgentSession(sessionId, session, immediate) } if (immediate) { - return this.forceKillPlainShellSession(sessionId, session) + // Why tracked like the agent path: this claims termination on the Session and then awaits + // an OS probe and taskkill, and a create landing inside that window must be able to wait it + // out rather than be told the id is absent (#18046). + return this.track(sessionId, session, immediate, () => + this.forceKillPlainShellSession(sessionId, session) + ) } session.kill() } + /** Publishes an operation for `sessionId` and retires it once the teardown settles. */ + private track( + sessionId: string, + session: Session, + immediate: boolean, + run: (entry: TeardownOperation) => Promise + ): Promise { + const entry: TeardownOperation = { + promise: Promise.resolve(), + immediate, + rootSignalled: false, + rootCompletion: Promise.resolve(), + session + } + const operation = run(entry) + entry.promise = operation + this.operations.set(sessionId, entry) + const clearOperation = (): void => { + if (this.operations.get(sessionId) === entry) { + this.operations.delete(sessionId) + } + } + void operation.then(clearOperation, clearOperation) + return operation + } + /** * Immediate teardown of a non-agent shell. On Windows, closing the ConPTY does not * reap orphaned children (node-pty `useConptyDll` skips the console-process reap), so a @@ -92,48 +129,34 @@ export class TerminalSessionTeardown { session.scheduleForceDisposeFallback() } - const entry: AgentTeardownOperation = { - promise: Promise.resolve(), - immediate, - rootSignalled: false, - rootCompletion: Promise.resolve(), - session - } - const sweep = Promise.resolve( - killWithDescendantSweep( - session.pid, - () => { - // Why: natural exit reaps the PID while ps is running. Never signal that - // stale numeric PID after the Session no longer represents a live root. - if (!session.isAlive) { - return + return this.track(sessionId, session, immediate, (entry) => { + const sweep = Promise.resolve( + killWithDescendantSweep( + session.pid, + () => { + // Why: natural exit reaps the PID while ps is running. Never signal that + // stale numeric PID after the Session no longer represents a live root. + if (!session.isAlive) { + return + } + entry.rootSignalled = true + if (entry.immediate) { + entry.rootCompletion = session.forceKillAndWaitForExit() + } else { + session.signalTerminationRoot() + } + }, + { + // Why: the descendant rows are only authoritative while this exact + // Session still owns the root PID captured by ps. + ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive, + terminateOwnedTree: () => session.terminateOwnedTree() } - entry.rootSignalled = true - if (entry.immediate) { - entry.rootCompletion = session.forceKillAndWaitForExit() - } else { - session.signalTerminationRoot() - } - }, - { - // Why: the descendant rows are only authoritative while this exact - // Session still owns the root PID captured by ps. - ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive, - terminateOwnedTree: () => session.terminateOwnedTree() - } + ) ) - ) - // Why: descendant capture completion only proves signals were requested; - // destructive callers must retain the native owner until OS-confirmed exit. - const operation = sweep.then(() => entry.rootCompletion) - entry.promise = operation - this.operations.set(sessionId, entry) - const clearOperation = (): void => { - if (this.operations.get(sessionId) === entry) { - this.operations.delete(sessionId) - } - } - void operation.then(clearOperation, clearOperation) - return operation + // Why: descendant capture completion only proves signals were requested; + // destructive callers must retain the native owner until OS-confirmed exit. + return sweep.then(() => entry.rootCompletion) + }) } } diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 2f40b0881e8..83b0eabc5ed 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -187,10 +187,15 @@ export async function removeStaleDurableWriteTempFiles( } /** Synchronous counterpart for quit and crash paths that cannot await. */ -export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void { +export function writeFileDurableSync( + tmpPath: string, + finalPath: string, + payload: string | Uint8Array +): void { let renamed = false try { - writeFileSync(tmpPath, payload, 'utf-8') + // A Uint8Array payload is written verbatim; a string still defaults to UTF-8. + writeFileSync(tmpPath, payload) const fd = openSync(tmpPath, 'r+') try { fsyncSync(fd) diff --git a/src/main/git/command-runner/gh-exec-file-deadline.test.ts b/src/main/git/command-runner/gh-exec-file-deadline.test.ts new file mode 100644 index 00000000000..3775b67a7ed --- /dev/null +++ b/src/main/git/command-runner/gh-exec-file-deadline.test.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, spawnMock, killSpawnedCommandTreeMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + spawnMock: vi.fn(), + killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock, + spawn: spawnMock +})) +vi.mock('./spawned-command-tree-kill', () => ({ + killSpawnedCommandTree: killSpawnedCommandTreeMock +})) + +import { ghExecFileAsync } from './gh-exec-file' + +function mockChild(pid = 4321): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = pid + child.kill = vi.fn(() => true) + child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + return child as unknown as ChildProcess +} + +/** + * The contract the star check depends on after #18234: a `gh` that never exits + * is killed at the deadline, tree and all, rather than running forever. + */ +describe('gh exec deadline', () => { + beforeEach(() => { + vi.useFakeTimers() + execFileMock.mockReset() + spawnMock.mockReset() + killSpawnedCommandTreeMock.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('kills the process tree and rejects when gh never exits', async () => { + const child = mockChild() + // Why never invoking the callback: this is exactly the stuck child from + // #18234 — spawned, spinning, and never reporting an exit. + execFileMock.mockReturnValue(child) + + const pending = ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { + timeout: 15_000 + }) + const rejection = expect(pending).rejects.toThrow('timed out') + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) + + // Not yet: the deadline has not elapsed. + expect(killSpawnedCommandTreeMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(15_000) + await rejection + + expect(killSpawnedCommandTreeMock).toHaveBeenCalledWith(child) + }) + + it('spawns with hidden console and captured stdio, never an inherited or shell stdio', async () => { + const child = mockChild() + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ) => { + callback(null, 'HTTP/2.0 204 No Content\r\n', '') + return child + } + ) + + await ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { timeout: 15_000 }) + + const [command, args, options] = execFileMock.mock.calls[0] + expect(command).toBe('gh') + expect(args).toEqual(['api', '--include', 'user/starred/stablyai/orca']) + // `execFile` captures stdout/stderr over pipes and never inherits Orca's; + // `shell` is never set, and the console stays hidden on Windows. + expect(options.windowsHide).toBe(true) + expect(options.stdio).toBeUndefined() + expect(options.shell).toBeUndefined() + }) +}) diff --git a/src/main/git/command-runner/gh-spawn-boundary.test.ts b/src/main/git/command-runner/gh-spawn-boundary.test.ts new file mode 100644 index 00000000000..b6d83daaa95 --- /dev/null +++ b/src/main/git/command-runner/gh-spawn-boundary.test.ts @@ -0,0 +1,71 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the gh chokepoint the way `child-process-import-boundary` guards spawn. + * + * `ghExecFileAsync` is what gives a gh invocation a deadline, a process-tree + * kill, transient-error retry, the rate-limit breaker, and WSL/host routing. + * Two call sites quietly opted out of all of it by reaching for the legacy + * `execFileAsync('gh', …)`, and one of them left `gh` children spinning at 100% + * CPU forever while permanently exhausting the GitHub concurrency semaphore + * (#18234). Nothing about those call sites looked wrong locally — which is why + * this is a tree-level rule rather than a review habit. + * + * The allowlist is empty and may only stay empty. + */ +const GH_SPAWN_PATTERN = + /(?:execFileAsync|commandExecFileAsync|execFileCapture|runProcess|spawnProcess|execFile|spawnSync|spawn)\s*\(\s*(['"`])gh\1|program:\s*(['"`])gh\2/ + +// Why trailing slash: a sibling like command-runner-extras.ts is scanned, not exempted. +const OWNER_DIRECTORY = 'src/main/git/command-runner/' +const SCANNED_EXTENSIONS = ['.ts', '.tsx'] +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__' +]) + +function isTestFile(path: string): boolean { + return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/') +} + +function collectSourceFiles(root: string): string[] { + let found: string[] = [] + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found = found.concat(collectSourceFiles(full)) + continue + } + if (SCANNED_EXTENSIONS.some((extension) => full.endsWith(extension))) { + found.push(full) + } + } + return found +} + +describe('gh spawn boundary', () => { + it('routes every gh invocation through ghExecFileAsync', () => { + const repoRoot = resolve(__dirname, '..', '..', '..', '..') + const offenders = collectSourceFiles(join(repoRoot, 'src')) + .map((path) => relative(repoRoot, path).split('\\').join('/')) + .filter((path) => !isTestFile(path) && !path.startsWith(OWNER_DIRECTORY)) + .filter((path) => GH_SPAWN_PATTERN.test(readFileSync(join(repoRoot, path), 'utf8'))) + + expect(offenders).toEqual([]) + }) +}) diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index a70c0ca2bc9..559e1821bd1 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -13,6 +13,7 @@ import { type WslProcessGroupTermination } from '../wsl-process-group-termination' import { translateArgForWsl, translateArgsForWsl } from './wsl-path-translation' +import { resolveWslInteropSpawnCwd } from '../../wsl-interop-spawn-directory' // Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) @@ -111,7 +112,7 @@ export function resolveCommand( ...(linuxCwd ? ['-C', linuxCwd] : []), ...translatedArgs ], - cwd: undefined, + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'direct-git' }, @@ -130,7 +131,7 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), - cwd: undefined, + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'login-shell', captured @@ -142,7 +143,7 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), - cwd: undefined, + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'login-shell' }, @@ -154,8 +155,11 @@ export function resolveCommand( { binary: 'wsl.exe', args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), - // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. - cwd: undefined, + // Why: the `cd` inside bash -c handles the Linux directory. This names an + // explicit Windows directory anyway, because `undefined` makes + // CreateProcessW inherit the parent's — which is a deletable WSL UNC path + // when Orca was launched from a worktree (#16463). + cwd: resolveWslInteropSpawnCwd(), wsl, wslMode: 'non-login-shell' }, diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index 96bb421b8e8..ce89fac3f69 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -157,7 +157,11 @@ export async function probeAnyExactRefBatched( } catch { return { found: false, unknown: true } } - const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + // Trim per line so a CRLF-translating host's `\r` does not become part of the type. + const lines = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) // One line per input, in order; a short read means the batch never answered for the rest. if (lines.length !== safeRefs.length) { return { found: false, unknown: true } diff --git a/src/main/git/fork-remote-refspec.ts b/src/main/git/fork-remote-refspec.ts index 5bf53cfe7f0..c924fdb2cb2 100644 --- a/src/main/git/fork-remote-refspec.ts +++ b/src/main/git/fork-remote-refspec.ts @@ -55,6 +55,30 @@ function refspecSource(refspec: string): string { return refspec.replace(/^\+/, '').split(':')[0]! } +/** + * True if `branchName`'s remote-tracking ref already exists locally under `remoteName`. + * Used to skip a redundant fetch on the common repeat-materialize case (the ref was + * already pulled in by an earlier mint/fetch) while still fetching it on demand the + * first time a sibling worktree widens an existing remote onto a new branch -- a bare + * refspec-config widen never itself imports anything (see `ensureRemoteTracksBranchNarrowly`). + */ +export async function forkRemoteTrackingRefExists( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + branchName: string +): Promise { + try { + await execGit( + ['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${branchName}`], + repoPath + ) + return true + } catch { + return false + } +} + /** * True only if `remote..url` is actually set. Deliberately plumbing (`config --get`), * not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name* diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 11ac2c21264..feb237eb18a 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -193,6 +193,17 @@ describe('git remote operations', () => { if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' + ].join('\n'), + stderr: '' + } + } if (args[0] === 'remote') { return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' } } @@ -207,6 +218,54 @@ describe('git remote operations', () => { ) }) + // Regression: normalizing a URL-valued push remote used to run `git remote` and then a + // serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo. + it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => { + const remotes = [ + { name: 'origin', url: 'https://github.com/stablyai/orca.git' }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + url: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' } + ] + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'imp/chinese-translation\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { + return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { + return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' } + } + if (args[0] === 'config') { + throw new Error(`config key is not set: ${args.join(' ')}`) + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: remotes + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n'), + stderr: '' + } + } + if (args[0] === 'remote') { + throw new Error(`unexpected remote scan: ${args.join(' ')}`) + } + return { stdout: '', stderr: '' } + }) + + await gitPush('/repo', false) + + const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote') + expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']]) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'], + { cwd: '/repo' } + ) + }) + it('uses an explicit push target even when it differs from the local branch name', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 2baf3b77137..20cf8415d04 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' @@ -84,6 +85,8 @@ type ConfiguredPushRemote = { branchRemote: string | null } +// One `git remote -v` instead of `git remote` plus a serial `git remote get-url` +// per remote; both print the same insteadOf-expanded fetch URL. async function findRemoteNameForUrl( worktreePath: string, remoteUrl: string, @@ -91,30 +94,13 @@ async function findRemoteNameForUrl( ): Promise { try { const { stdout } = await gitExecFileAsync( - ['remote'], + ['remote', '-v'], gitOptionsForWorktree(worktreePath, options) ) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await gitExecFileAsync( - ['remote', 'get-url', remoteName], - gitOptionsForWorktree(worktreePath, options) - ) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } async function normalizePushRemote( @@ -141,11 +127,17 @@ async function getConfiguredPushRemote( if (!remote) { return null } + const normalizedRemote = await normalizePushRemote(worktreePath, remote, options) + // The two usually name the same URL; resolving it twice reads the remote table twice. + if (!branchRemote) { + return { remote: normalizedRemote, branchRemote: null } + } return { - remote: await normalizePushRemote(worktreePath, remote, options), - branchRemote: branchRemote - ? await normalizePushRemote(worktreePath, branchRemote, options) - : null + remote: normalizedRemote, + branchRemote: + branchRemote === remote + ? normalizedRemote + : await normalizePushRemote(worktreePath, branchRemote, options) } } diff --git a/src/main/git/repo-branch-conflict-batched-probe.test.ts b/src/main/git/repo-branch-conflict-batched-probe.test.ts new file mode 100644 index 00000000000..e6e672c65a2 --- /dev/null +++ b/src/main/git/repo-branch-conflict-batched-probe.test.ts @@ -0,0 +1,102 @@ +// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a +// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes +// the one-line-per-ref contract. Every batch then came back undecided and fell through to +// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests +// pin the fence request and the resulting subprocess count at 58 remotes. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { getBranchConflictKind } from './repo-branch-conflict' + +const REMOTES = Array.from({ length: 58 }, (_, index) => `r${index}`) +const BRANCH = 'user/feature' +const WSL_BANNER = + 'Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64)\n' + + 'To run a command as administrator (user "root"), use "sudo ".\n' + +type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean } + +/** + * Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless + * the caller asked for the fenced form, which slices the payload back out. + */ +function installLoginShellRunner(): { argv: string[][] } { + const argv: string[][] = [] + gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => { + argv.push(args) + if (args[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (args[0] === 'remote') { + return { stdout: `${WSL_BANNER}${REMOTES.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing ref'), { code: 1, stderr: '' }) + } + if (args[0] === 'cat-file') { + const payload = `${(options.stdin ?? '') + .split('\n') + .filter(Boolean) + .map((ref) => `${ref} missing`) + .join('\n')}\n` + return { + stdout: options.captureWslLoginShellOutput ? payload : `${WSL_BANNER}${payload}`, + stderr: '' + } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + return { argv } +} + +function countSubcommand(argv: readonly string[][], subcommand: string): number { + return argv.filter((args) => args[0] === subcommand).length +} + +describe('getBranchConflictKind batched remote probe', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('asks the WSL login shell to fence the batch payload it parses', async () => { + installLoginShellRunner() + + await getBranchConflictKind('/repo', BRANCH) + + const batchCall = gitExecFileAsyncMock.mock.calls.find(([args]) => args[0] === 'cat-file') + expect(batchCall?.[1]).toMatchObject({ captureWslLoginShellOutput: true }) + }) + + it('answers from one batched subprocess instead of one show-ref per remote', async () => { + const { argv } = installLoginShellRunner() + + await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBeNull() + + expect(countSubcommand(argv, 'cat-file')).toBe(1) + expect(countSubcommand(argv, 'show-ref')).toBe(0) + }) + + it('still falls back to per-ref probes when the batch itself fails', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (args[0] === 'remote') { + return { stdout: `${REMOTES.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'cat-file') { + throw new Error('cat-file is unavailable on this host') + } + if (args[0] === 'show-ref') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBe('remote') + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index c799d3770be..5c6e03b94b0 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -147,10 +147,12 @@ export function getBranchConflictKind( const execOptions = gitExecOptions(path, options) const runLocalGit = ( argv: string[], - commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + commandOptions?: ExactRefProbeExecOptions & { stdin?: string }, + captureWslLoginShellOutput = false ): Promise<{ stdout: string }> => gitExecFileAsync(argv, { ...execOptions, + ...(captureWslLoginShellOutput ? { captureWslLoginShellOutput: true } : {}), ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) @@ -160,7 +162,13 @@ export function getBranchConflictKind( branchName, allowedBaseRef, {}, - (argv, commandOptions) => runLocalGit(argv, commandOptions) + // Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes + // the distro's rc/motd banner to that same stream. The extra lines break the + // one-line-per-ref contract, so every batch came back undecided and fell through to + // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove. + // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs + // no fence; the capture wrapper preserves the payload's exit status either way. + (argv, commandOptions) => runLocalGit(argv, commandOptions, true) ) } diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 1974c4e2384..27d7a72c747 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -626,7 +626,11 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below). + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) // A read also warms the direct-git environment probe in the background, so @@ -659,7 +663,11 @@ describe('runner execFile timeout handling', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', expect.any(String)], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below). + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 57dd86ba26c..5f933c60620 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -99,7 +99,10 @@ describe('ghExecFileAsync WSL fallback', () => { '-c', "cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" ], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) expect(execFileMock).toHaveBeenNthCalledWith( @@ -382,7 +385,11 @@ describe('ghExecFileAsync WSL fallback', () => { 2, 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'gh' 'api' 'rate_limit'"], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. This global call has no repo directory at all, so nothing about + // where it runs changes. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) }) @@ -501,7 +508,11 @@ describe('ghExecFileAsync WSL fallback', () => { 2, 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'api' 'projects'"], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. This global call has no repo directory at all, so nothing about + // where it runs changes. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) }) diff --git a/src/main/git/upstream-deferred-fork-remote-real.test.ts b/src/main/git/upstream-deferred-fork-remote-real.test.ts new file mode 100644 index 00000000000..451f7bed8b3 --- /dev/null +++ b/src/main/git/upstream-deferred-fork-remote-real.test.ts @@ -0,0 +1,59 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { GitPushTarget } from '../../shared/worktree/types' +import { getUpstreamStatus } from './upstream' + +// Why: on-demand remote materialization (#17828) defers `git remote add` for a +// fork PR to first push/pull/fetch/fast-forward, so an unpublished review's +// status must be read against a pushTarget whose remote was never created. +// This exercises the real `rev-parse --verify --quiet` failure path -- a +// fake/mocked git can't reproduce its exact exit-code/stderr shape, which is +// exactly what `getPublishTargetStatus`'s missing-ref fallback depends on. +describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('reports the graceful "publish" state instead of 0 ahead/0 behind', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-deferred-fork-remote-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '-m', 'base') + git('branch', '-M', 'contributor/fix') + + // Simulates a fork-PR review worktree right after create: pushTarget + // metadata is persisted, but `pr-contributor-orca` was never added as a + // remote because materialization is deferred to first use. + const pushTarget: GitPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' + } + + const status = await getUpstreamStatus(repoPath, pushTarget) + + expect(status).toEqual({ + hasUpstream: false, + upstreamName: 'pr-contributor-orca/contributor/fix', + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + }) + }) +}) diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 701808a8a31..b7644ad5543 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -363,6 +363,16 @@ describe('getUpstreamStatus', () => { if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) } + if (args[0] === 'remote' && args[1] === '-v') { + return Promise.resolve({ + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' + ].join('\n') + }) + } if (args[0] === 'remote') { return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' }) } diff --git a/src/main/git/worktree-base-divergence-real-git.test.ts b/src/main/git/worktree-base-divergence-real-git.test.ts index e17192cf914..377b63b48f9 100644 --- a/src/main/git/worktree-base-divergence-real-git.test.ts +++ b/src/main/git/worktree-base-divergence-real-git.test.ts @@ -32,9 +32,17 @@ async function createRepo(): Promise { return repoPath } +// Why unique across calls: an empty commit's hash covers only parent, tree, message and a +// one-second-granularity timestamp. On a fast runner the whole 100-commit build finishes inside +// one second, so a post-reset `commit 0` off the same fork point hashed identically to the first +// `commit 0` of the chain and Git handed back that same object — leaving the branch 99/0 apart +// instead of 100/1. +let emptyCommitSequence = 0 + function commitEmpty(repoPath: string, count: number): void { for (let index = 0; index < count; index += 1) { - git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`]) + emptyCommitSequence += 1 + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${emptyCommitSequence}`]) } } diff --git a/src/main/github/client-starred.test.ts b/src/main/github/client-starred.test.ts index 4b118ad8afb..2997aef883a 100644 --- a/src/main/github/client-starred.test.ts +++ b/src/main/github/client-starred.test.ts @@ -26,47 +26,146 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { checkOrcaStarred } from './client' +import { __resetOrcaStarCheckForTests, checkOrcaStarred, starOrca } from './client' import { resetOriginRepositoryCache } from './client-test-harness' -const { execFileAsyncMock, acquireMock, releaseMock } = clientMocks +const { execFileAsyncMock, ghExecFileAsyncMock, acquireMock, releaseMock } = clientMocks + +/** Let the coalesced check reach its `await acquire()` continuation and spawn gh. */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve() + } +} describe('checkOrcaStarred', () => { - beforeEach(() => { + beforeEach(async () => { resetOriginRepositoryCache() execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) + __resetOrcaStarCheckForTests() }) it('returns true only for an included successful GitHub response', async () => { - execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(true) - expect(execFileAsyncMock).toHaveBeenCalledWith( - 'gh', + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--include', 'user/starred/stablyai/orca'], - { encoding: 'utf-8' } + expect.objectContaining({ encoding: 'utf-8' }) ) }) it('returns true for an HTTP 200 starred response', async () => { - execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 200 OK\r\n', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 200 OK\r\n', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(true) }) it('returns false for GitHub 404 not starred responses', async () => { - execFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) await expect(checkOrcaStarred()).resolves.toBe(false) }) it('returns null when gh exits successfully without response headers', async () => { - execFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(null) }) + + // ── #18234: an unbounded, unreaped, un-deduped star check ────────────── + + it('never spawns gh directly, so the spawn carries a deadline and a tree kill', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + + await checkOrcaStarred() + + // Why: the raw execFileAsync has no timeout, so a `gh` that never exits ran + // forever at 100% CPU and was never reaped. ghExecFileAsync bounds the child + // and kills its process tree on the deadline. + expect(execFileAsyncMock).not.toHaveBeenCalled() + const [, options] = ghExecFileAsyncMock.mock.calls[0] + expect(typeof options.timeout).toBe('number') + expect(options.timeout).toBeGreaterThan(0) + expect(Number.isFinite(options.timeout)).toBe(true) + }) + + it('coalesces concurrent checks onto one gh child', async () => { + let resolveGh: (value: { stdout: string; stderr: string }) => void = () => {} + ghExecFileAsyncMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveGh = resolve + }) + ) + + const first = checkOrcaStarred() + const second = checkOrcaStarred() + const third = checkOrcaStarred() + await flushMicrotasks() + + // Why: five call sites can ask at once; without coalescing each forked its + // own `gh` and four stuck children exhausted the GitHub semaphore. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(acquireMock).toHaveBeenCalledTimes(1) + + resolveGh({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + }) + + it('starts a fresh check once the previous one has settled', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'HTTP/2.0 404 Not Found\r\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + + await checkOrcaStarred() + await expect(checkOrcaStarred()).resolves.toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('releases its GitHub concurrency slot when gh fails or times out', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('gh timed out.')) + + await expect(checkOrcaStarred()).resolves.toBe(null) + + // Why: a leaked slot is permanent — four of them wedge every GitHub feature + // in the app for the rest of the session. + expect(releaseMock).toHaveBeenCalledTimes(1) + expect(acquireMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('starOrca', () => { + beforeEach(async () => { + resetOriginRepositoryCache() + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + __resetOrcaStarCheckForTests() + }) + + it('stars through the bounded gh runner and releases its slot', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect(starOrca()).resolves.toBe(true) + + expect(execFileAsyncMock).not.toHaveBeenCalled() + const [args, options] = ghExecFileAsyncMock.mock.calls[0] + expect(args).toEqual(['api', '-X', 'PUT', 'user/starred/stablyai/orca']) + expect(options.timeout).toBeGreaterThan(0) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('reports failure and still releases its slot when gh times out', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('gh timed out.')) + + await expect(starOrca()).resolves.toBe(false) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3adec83a13a..fa331749c8b 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -8,7 +8,7 @@ export { __resetTrackedUpstreamBranchCacheForTests } from './client/lookup/tracked-upstream-cache' export { addPRReviewComment, addPRReviewCommentReply } from './client/create/add-pr-review-comment' -export { checkOrcaStarred, starOrca } from './client/fetch/orca-star' +export { __resetOrcaStarCheckForTests, checkOrcaStarred, starOrca } from './client/fetch/orca-star' export { countWorkItems } from './client/list/count-work-items' export { createGitHubPullRequest } from './client/create/create-github-pull-request' export { getAuthenticatedViewer } from './client/fetch/authenticated-viewer' diff --git a/src/main/github/client/fetch/authenticated-viewer.ts b/src/main/github/client/fetch/authenticated-viewer.ts index dd297addd67..551ffe81377 100644 --- a/src/main/github/client/fetch/authenticated-viewer.ts +++ b/src/main/github/client/fetch/authenticated-viewer.ts @@ -1,14 +1,17 @@ import type { GitHubViewer } from '../../../../shared/github/pull-request-types' -import { execFileAsync, acquire, release } from '../../gh-utils' +import { ghExecFileAsync, acquire, release } from '../../gh-utils' /** * Get the authenticated GitHub viewer when gh is available and logged in. * Returns null when gh is unavailable, unauthenticated, or the lookup fails. + * + * Runs through `ghExecFileAsync` for its deadline and tree kill: a `gh` that + * never exits would otherwise hold one of the four GitHub concurrency slots + * forever (#18234). */ export async function getAuthenticatedViewer(): Promise { await acquire() try { - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( ['api', 'user', '--jq', '{login: .login, email: .email}'], { encoding: 'utf-8' } ) diff --git a/src/main/github/client/fetch/orca-star.ts b/src/main/github/client/fetch/orca-star.ts index ddf63d71c17..a9006ed544a 100644 --- a/src/main/github/client/fetch/orca-star.ts +++ b/src/main/github/client/fetch/orca-star.ts @@ -1,17 +1,45 @@ -import { execFileAsync, acquire, release } from '../../gh-utils' +import { ghExecFileAsync, acquire, release } from '../../gh-utils' export const ORCA_REPO = 'stablyai/orca' +/** + * Deadline for the two star-nag gh calls. + * + * Why bounded at all: these are the only gh call sites that used the raw + * `execFileAsync`, so a `gh` that never exits blocked forever, never released + * its GitHub concurrency slot, and left the child running (#18234). Why shorter + * than the 30s gh default: nothing here is user-visible work — the nag falls + * back to the browser button — so a slow answer is worth less than a bounded one. + */ +const STAR_GH_TIMEOUT_MS = 15_000 + +let inFlightStarCheck: Promise | null = null + /** * Check if the authenticated user has starred the Orca repo. * Returns true if starred, false if not, null if unable to determine (gh unavailable). */ -export async function checkOrcaStarred(): Promise { +export function checkOrcaStarred(): Promise { + // Why: five independent callers (landing button, settings section, threshold + // nag, agent-value moment, force-show) can ask at once and none of them knows + // about the others. Without coalescing, each forks its own `gh`, and four + // stuck children exhaust the 4-wide GitHub semaphore for the app's lifetime. + inFlightStarCheck ??= runOrcaStarredCheck().finally(() => { + inFlightStarCheck = null + }) + return inFlightStarCheck +} + +/** @internal Drop any coalesced check so suites cannot inherit one another's. */ +export function __resetOrcaStarCheckForTests(): void { + inFlightStarCheck = null +} + +async function runOrcaStarredCheck(): Promise { await acquire() try { - const { stdout, stderr } = await execFileAsync( - 'gh', + const { stdout, stderr } = await ghExecFileAsync( ['api', '--include', `user/starred/${ORCA_REPO}`], - { encoding: 'utf-8' } + { encoding: 'utf-8', timeout: STAR_GH_TIMEOUT_MS } ) const response = `${stdout ?? ''}\n${stderr ?? ''}` if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) { @@ -24,7 +52,7 @@ export async function checkOrcaStarred(): Promise { if (message.includes('HTTP 404')) { return false } - // Anything else (gh not installed, not authenticated, network issue) + // Anything else (gh not installed, not authenticated, network issue, timeout) return null } finally { release() @@ -37,8 +65,9 @@ export async function checkOrcaStarred(): Promise { export async function starOrca(): Promise { await acquire() try { - await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], { - encoding: 'utf-8' + await ghExecFileAsync(['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], { + encoding: 'utf-8', + timeout: STAR_GH_TIMEOUT_MS }) return true } catch { diff --git a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts index a3fe62c9063..40da88f0c91 100644 --- a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts @@ -76,7 +76,11 @@ describe('glab known-hosts probe on Windows', () => { expect(execFileMock).toHaveBeenCalledWith( 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"], - expect.objectContaining({ cwd: undefined }), + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. This probe has no repo directory at all, so nothing about where + // it runs changes. The native `glab` assertion above keeps `undefined`. + expect.objectContaining({ cwd: expect.any(String) }), expect.any(Function) ) }) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index e9e7a728e83..cd6c21526d8 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -8,6 +8,8 @@ import { makePaneKey } from '../../shared/stable-pane-id' // evicts the entry. const dropStatusEntry = vi.fn() +const dropPersistedStatusEntry = vi.fn() +const dropPersistedStatusEntries = vi.fn(() => [] as string[]) const dropStatusEntriesByTabPrefix = vi.fn() const retirePaneAuthority = vi.fn() const transferPaneAuthority = vi.fn() @@ -44,6 +46,8 @@ vi.mock('../agent-hooks/server', async () => { ...actual, agentHookServer: { dropStatusEntry, + dropPersistedStatusEntry, + dropPersistedStatusEntries, dropStatusEntriesByTabPrefix, retirePaneAuthority, transferPaneAuthority, @@ -105,6 +109,9 @@ vi.mock('../kimi/hook-service', () => ({ beforeEach(() => { dropStatusEntry.mockReset() + dropPersistedStatusEntry.mockReset() + dropPersistedStatusEntries.mockReset() + dropPersistedStatusEntries.mockReturnValue([]) dropStatusEntriesByTabPrefix.mockReset() retirePaneAuthority.mockReset() transferPaneAuthority.mockReset() @@ -279,6 +286,71 @@ describe('agentStatus:drop IPC', () => { }) }) +describe('agentStatus:dropPersisted IPC', () => { + it('forwards a validated cache identity without clearing pane state', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:dropPersisted') + expect(handler).toBeDefined() + const identity = { + paneKey: PANE_KEY, + receivedAt: 2_000, + stateStartedAt: 1_000 + } + handler!({}, identity) + expect(dropPersistedStatusEntry).toHaveBeenCalledWith(identity) + expect(dropStatusEntry).not.toHaveBeenCalled() + }) + + it('forwards a batch, keeping only valid identities, and clears migration state per evicted pane', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:dropPersistedBatch') + expect(handler).toBeDefined() + const good = { paneKey: PANE_KEY, receivedAt: 2_000, stateStartedAt: 1_000 } + const alsoGood = { paneKey: CHILD_PANE_KEY, receivedAt: 3_000, stateStartedAt: 2_500 } + dropPersistedStatusEntries.mockReturnValue([PANE_KEY]) + handler!({}, [good, { paneKey: 'not-a-pane-key', receivedAt: 1, stateStartedAt: 1 }, alsoGood]) + expect(dropPersistedStatusEntries).toHaveBeenCalledWith([good, alsoGood]) + expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) + expect(clearMigrationUnsupportedPtysForPaneKey).not.toHaveBeenCalledWith(CHILD_PANE_KEY) + expect(dropPersistedStatusEntry).not.toHaveBeenCalled() + }) + + it('ignores a batch that is not an array or is empty after validation', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:dropPersistedBatch')! + for (const value of [null, {}, 'x', [], [{ paneKey: PANE_KEY }]]) { + expect(() => handler({}, value)).not.toThrow() + } + expect(dropPersistedStatusEntries).not.toHaveBeenCalled() + }) + + it('rejects malformed cache identities', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:dropPersisted')! + for (const value of [ + null, + undefined, + {}, + { paneKey: PANE_KEY }, + { paneKey: PANE_KEY, receivedAt: Number.NaN, stateStartedAt: 1 }, + { paneKey: PANE_KEY, receivedAt: 2, stateStartedAt: Number.POSITIVE_INFINITY }, + { paneKey: 'not-a-pane-key', receivedAt: 2, stateStartedAt: 1 }, + { paneKey: PANE_KEY, receivedAt: '2', stateStartedAt: 1 } + ]) { + expect(() => handler({}, value)).not.toThrow() + } + expect(dropPersistedStatusEntry).not.toHaveBeenCalled() + }) +}) + describe('agentStatus:dropByTabPrefix IPC', () => { it('forwards valid tab ids to tab-prefix cache eviction', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index e33e1c93789..020cfa7259d 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' +import type { AgentStatusCacheIdentity } from '../../shared/agent-status-types' import { clearMigrationUnsupportedPtysByTabPrefix, clearMigrationUnsupportedPtysForPaneKey @@ -7,7 +8,7 @@ import { import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary' /** - * The three renderer-initiated ways a status row goes away. All fire-and-forget + * The renderer-initiated ways a status row goes away. All fire-and-forget * (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the * listeners first keeps re-registration safe. * @@ -15,8 +16,13 @@ import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary' * still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves * the pane's next event straight back to `working`. */ +// Why a cap: the renderer sends one batch per Clear-completed click, bounded by visible rows. +const MAX_DROP_PERSISTED_BATCH = 5_000 + export function registerAgentStatusRowTeardownIpcHandlers(): void { ipcMain.removeAllListeners('agentStatus:drop') + ipcMain.removeAllListeners('agentStatus:dropPersisted') + ipcMain.removeAllListeners('agentStatus:dropPersistedBatch') ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess') ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') @@ -36,6 +42,36 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { } }) + ipcMain.on('agentStatus:dropPersisted', (_event, request: unknown) => { + if (!isValidAgentStatusCacheIdentity(request)) { + return + } + try { + if (agentHookServer.dropPersistedStatusEntry(request)) { + clearMigrationUnsupportedPtysForPaneKey(request.paneKey) + } + } catch (err) { + console.warn('[agent-hooks] dropPersistedStatusEntry failed:', err) + } + }) + + ipcMain.on('agentStatus:dropPersistedBatch', (_event, request: unknown) => { + if (!Array.isArray(request) || request.length > MAX_DROP_PERSISTED_BATCH) { + return + } + const identities = request.filter(isValidAgentStatusCacheIdentity) + if (identities.length === 0) { + return + } + try { + for (const paneKey of agentHookServer.dropPersistedStatusEntries(identities)) { + clearMigrationUnsupportedPtysForPaneKey(paneKey) + } + } catch (err) { + console.warn('[agent-hooks] dropPersistedStatusEntries failed:', err) + } + }) + ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => { if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) { return @@ -67,3 +103,18 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { } }) } + +function isValidAgentStatusCacheIdentity(value: unknown): value is AgentStatusCacheIdentity { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const request = value as Record + return ( + typeof request.paneKey === 'string' && + isValidPaneKey(request.paneKey) && + typeof request.receivedAt === 'number' && + Number.isFinite(request.receivedAt) && + typeof request.stateStartedAt === 'number' && + Number.isFinite(request.stateStartedAt) + ) +} diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 13346047d86..84d8d0a2306 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -95,7 +95,11 @@ describe('createWslWatcher', () => { ['-d', 'Ubuntu', '--exec', 'sh', '-s', '--', '/home/me/repo'], expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + // Why a concrete directory (#16463): the watched path rides in argv, so + // an omitted cwd only means CreateProcessW inherits Orca's -- a worktree + // that can be deleted, after which every watcher start is ENOENT. + cwd: expect.any(String) }) ) }) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 86f10c2ee68..a444113c1cf 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -14,6 +14,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' import { createDebouncedBatch, type DebouncedBatch } from './filesystem-watcher-batch-control' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' export type WatcherSubscription = { unsubscribe(): Promise @@ -244,7 +245,11 @@ export async function createWslWatcher( try { child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', linuxPath], { stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true + windowsHide: true, + // Why explicit (#16463): the watched directory rides in argv, and an + // inherited cwd is a worktree that can be deleted -- after which every + // watcher start fails `spawn wsl.exe ENOENT`. + cwd: resolveWslInteropSpawnCwd() }) } catch (error) { releaseChildReservation() diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts index 53ba72d58a4..2c3273b8c00 100644 --- a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -9,6 +9,10 @@ import { import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void { @@ -20,6 +24,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl _event, args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -36,7 +41,18 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + // Why: a fork remote deferred at create time (#17828) must exist before push. + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.pushBranch(args.worktreePath, publish, materializedPushTarget, { forceWithLease: args.forceWithLease === true }) } @@ -46,13 +62,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitPush(worktreePath, publish, args.pushTarget, { + await gitPush(worktreePath, publish, materializedPushTarget, { forceWithLease: args.forceWithLease === true, ...gitOptions, admissionTier: 'interactive' @@ -64,7 +90,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl 'git:pull', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -74,7 +105,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pullBranch(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.pullBranch(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -82,13 +123,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitPull(worktreePath, args.pushTarget, { + await gitPull(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) @@ -99,7 +150,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl 'git:fastForward', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -109,7 +165,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.fastForwardBranch(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -117,13 +183,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitFastForward(worktreePath, args.pushTarget, { + await gitFastForward(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts index eae79c918dd..a924c393a04 100644 --- a/src/main/ipc/filesystem/git-remote/sync-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -17,6 +17,10 @@ import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-c import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void { @@ -52,7 +56,12 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) 'git:fetch', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -62,7 +71,17 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.fetchRemote(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.fetchRemote(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -70,13 +89,23 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitFetch(worktreePath, args.pushTarget, { + await gitFetch(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts new file mode 100644 index 00000000000..20e0e646da4 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts @@ -0,0 +1,149 @@ +// Real-binary coverage for #17828's remaining gap: the mocked-underlying-trigger suite in +// `spawn-push-target-materialization.test.ts` proves the wiring/delegation logic, but not +// that a `pty:spawn`-originated terminal -- the desktop GUI's own terminal path, previously +// uncovered -- actually ends up with a configured upstream against real git. No mocks here: +// this exercises the real `triggerTerminalSpawnPushTargetMaterialization` and real +// `materializeWorktreePushTargetRemote`, driven only through `runPtyIpcSpawn`'s hook. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence' +import type { PtySpawnIpcDeps } from './spawn-types' +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' +const TRACKED_BRANCH = 'contributor/fix' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' +let worktreeId = '' +let mainBranch = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function setIdentity(cwd: string): Promise { + await git(['config', 'user.name', 'Orca Test'], cwd) + await git(['config', 'user.email', 'orca@example.test'], cwd) + await git(['config', 'commit.gpgSign', 'false'], cwd) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pty-spawn-push-target-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + worktreeId = `${REPO_ID}::${repoPath}` + + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await setIdentity(repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + mainBranch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).trim() + + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await setIdentity(forkPath) + await git(['checkout', '-qb', TRACKED_BRANCH], forkPath) + await writeFile(join(forkPath, 'fix.txt'), 'fix\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fix'], forkPath) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +function forkTarget(): GitPushTarget { + return { remoteName: FORK_REMOTE, branchName: TRACKED_BRANCH, remoteUrl: forkPath } +} + +function depsFor( + pushTarget: GitPushTarget, + setWorktreeMeta?: Store['setWorktreeMeta'] +): { + deps: PtySpawnIpcDeps + meta: Record +} { + const meta: Record = { [worktreeId]: { pushTarget } as WorktreeMeta } + const store = { + getWorktreeMeta: (id: string) => meta[id], + getRepo: (id: string) => ({ id, path: repoPath, connectionId: null }) as unknown as Repo, + getAllWorktreeMeta: () => meta, + ...(setWorktreeMeta ? { setWorktreeMeta } : {}) + } as unknown as Store + return { deps: { store } as unknown as PtySpawnIpcDeps, meta } +} + +describe('triggerPtySpawnPushTargetMaterialization (real git fixture)', () => { + it('materializes the fork remote and configures the upstream for a pty:spawn-originated terminal', async () => { + // Why: not just that materialization was *called* -- the coordinator's bar for closing + // the gap is a real, git-verified configured upstream reachable from a pty:spawn arg set. + // Pre-seeds the remote so materialize takes the short-circuit branch (worktree-remote.ts): + // real `remote add`/`fetch` against a fabricated fork is already covered against real git by + // worktree-push-target-refspec-real-git.test.ts; the top-level entry point this hook calls + // additionally validates `remoteUrl` against a GitHub URL shape, which a local fixture path + // can never satisfy. The short-circuit is also the common case in practice -- every pty:spawn + // after the worktree's first (new tab, split, reattach) -- and still drives real + // `ensureRemoteTracksBranchNarrowly` / narrow `fetch` / `--set-upstream-to` git calls. + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + const { deps } = depsFor(forkTarget()) + + triggerPtySpawnPushTargetMaterialization(deps, { + cols: 80, + rows: 24, + worktreeId + }) + + await vi.waitFor( + async () => { + const upstream = await git( + ['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], + repoPath + ).catch(() => '') + expect(upstream.trim()).toBe(`${FORK_REMOTE}/${TRACKED_BRANCH}`) + }, + { timeout: 5000, interval: 25 } + ) + + const remoteUrl = (await git(['remote', 'get-url', FORK_REMOTE], repoPath)).trim() + expect(remoteUrl).toBe(forkPath) + + // The tracked branch's commit must actually be present -- confirms the narrow fetch ran, + // not just that the remote config was written. + const forkHead = (await git(['rev-parse', TRACKED_BRANCH], forkPath)).trim() + const fetchedHead = ( + await git(['rev-parse', `${FORK_REMOTE}/${TRACKED_BRANCH}`], repoPath) + ).trim() + expect(fetchedHead).toBe(forkHead) + }) + + it('is a no-op once the remote was already created (repeat pty:spawn, e.g. reattach)', async () => { + const target = { ...forkTarget(), remoteCreated: true } + const { deps } = depsFor(target) + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + + triggerPtySpawnPushTargetMaterialization(deps, { cols: 80, rows: 24, worktreeId }) + + // Give the fire-and-forget chain a tick; there is nothing to wait for since a + // remoteCreated target must short-circuit before any git call. + await new Promise((resolve) => setImmediate(resolve)) + const upstream = await git(['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], repoPath).catch( + () => '' + ) + expect(upstream.trim()).toBe('') + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts new file mode 100644 index 00000000000..df71cb37f8a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const { triggerMock } = vi.hoisted(() => ({ triggerMock: vi.fn() })) +vi.mock('../../../runtime/runtime-terminal-spawn-push-target-materialization', () => ({ + triggerTerminalSpawnPushTargetMaterialization: triggerMock +})) + +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' + +const REPO_ID = 'repo-1' +const WORKTREE_PATH = '/repo/worktree' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const FORK_TARGET: GitPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' +} +const REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo + +function depsWithStore(overrides: Partial = {}): PtySpawnIpcDeps { + return { + store: { + getWorktreeMeta: vi.fn().mockReturnValue({ pushTarget: FORK_TARGET } as WorktreeMeta), + getRepo: vi.fn().mockReturnValue(REPO), + ...overrides + } as unknown as Store + } as unknown as PtySpawnIpcDeps +} + +function baseArgs(overrides: Partial = {}): PtySpawnIpcArgs { + return { cols: 80, rows: 24, worktreeId: WORKTREE_ID, ...overrides } +} + +describe('triggerPtySpawnPushTargetMaterialization', () => { + let warnSpy: ReturnType + + beforeEach(() => { + triggerMock.mockReset() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('is a no-op when args has no worktreeId', () => { + triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs({ worktreeId: undefined })) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('is a no-op when deps has no store', () => { + triggerPtySpawnPushTargetMaterialization({} as unknown as PtySpawnIpcDeps, baseArgs()) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a malformed worktreeId (no separator)', () => { + triggerPtySpawnPushTargetMaterialization( + depsWithStore(), + baseArgs({ worktreeId: 'not-a-valid-id' }) + ) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('parses the worktreeId, looks up the push target and repo, and delegates', () => { + const deps = depsWithStore() + triggerPtySpawnPushTargetMaterialization(deps, baseArgs()) + + expect(deps.store!.getWorktreeMeta).toHaveBeenCalledWith(WORKTREE_ID) + expect(deps.store!.getRepo).toHaveBeenCalledWith(REPO_ID) + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + REPO, + deps.store, + REPO_ID, + WORKTREE_ID + ) + }) + + it('passes null when the repo lookup misses', () => { + const deps = depsWithStore({ getRepo: vi.fn().mockReturnValue(undefined) }) + triggerPtySpawnPushTargetMaterialization(deps, baseArgs()) + + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + null, + deps.store, + REPO_ID, + WORKTREE_ID + ) + }) + + // Why: many pty:spawn unit tests supply a narrow fake Store missing these methods -- + // this is the actual bug the hook must guard against (#17828), not a hypothetical. + // Optional chaining degrades the lookups to undefined/null; the underlying trigger + // itself no-ops on an undefined push target, so this never blocks or throws on spawn. + it('does not throw when the store lacks getWorktreeMeta/getRepo, delegating with undefined/null', () => { + const partialStore = {} as Store + expect(() => + triggerPtySpawnPushTargetMaterialization( + { store: partialStore } as unknown as PtySpawnIpcDeps, + baseArgs() + ) + ).not.toThrow() + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + undefined, + null, + partialStore, + REPO_ID, + WORKTREE_ID + ) + }) + + it('warns and swallows an error thrown by the underlying trigger', () => { + triggerMock.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs()) + ).not.toThrow() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to trigger push target materialization'), + expect.any(Error) + ) + }) + + it('strips a folder-workspace instance suffix from the worktree path before delegating', () => { + const instanceId = 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f789' + const deps = depsWithStore() + const suffixedId = `${WORKTREE_ID}::workspace:${instanceId}` + triggerPtySpawnPushTargetMaterialization(deps, baseArgs({ worktreeId: suffixedId })) + + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + REPO, + deps.store, + REPO_ID, + suffixedId + ) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts new file mode 100644 index 00000000000..d9a30326155 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts @@ -0,0 +1,40 @@ +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' +import { triggerTerminalSpawnPushTargetMaterialization } from '../../../runtime/runtime-terminal-spawn-push-target-materialization' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +// Why (#17828): pty:spawn is the desktop GUI's own terminal path (new tab, split, reattach) -- +// raw git commands can run here before any Orca-driven sync, so a deferred fork-PR remote must +// exist first. Mirrors the agent/background-terminal hook in +// runtime-terminal-spawn-push-target-materialization.ts, which this delegates to; fire-and-forget +// and a no-op once the remote already exists, so it is safe on every spawn including reattaches. +export function triggerPtySpawnPushTargetMaterialization( + deps: PtySpawnIpcDeps, + args: PtySpawnIpcArgs +): void { + if (!args.worktreeId || !deps.store) { + return + } + const parsed = splitWorktreeIdForFilesystem(args.worktreeId) + if (!parsed) { + return + } + // Why: never let a partial/fake Store (many pty:spawn unit tests supply a narrow one) or an + // unexpected lookup failure turn this best-effort hook into a spawn-blocking exception. + try { + const pushTarget = deps.store.getWorktreeMeta?.(args.worktreeId)?.pushTarget + const repo = deps.store.getRepo?.(parsed.repoId) ?? null + triggerTerminalSpawnPushTargetMaterialization( + parsed.worktreePath, + pushTarget, + repo, + deps.store, + parsed.repoId, + args.worktreeId + ) + } catch (error) { + console.warn( + `[pty-spawn] failed to trigger push target materialization for ${args.worktreeId}:`, + error + ) + } +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 748eb5d8f62..82e2d33f383 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -7,6 +7,7 @@ import { buildPtyIpcSpawnOptions } from './spawn-options' import { executePtyIpcSpawn } from './spawn-execute' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { @@ -30,6 +31,7 @@ function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void { } export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { + triggerPtySpawnPushTargetMaterialization(deps, args) const ctx = createPtyIpcSpawnState(deps, args) const early = await beginPtyIpcSpawn(ctx) if (early) { diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index de9da98ae38..97cef8da5e0 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -113,7 +113,7 @@ describe('repos:add with git worktrees', () => { invalidateAuthorizedRootsCacheMock.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('returns the tracked main checkout instead of adding its linked worktree', async () => { diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 44a16f8e10d..cc9081ce2a2 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -151,7 +151,7 @@ describe('repos:create', () => { gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) homedirMock.mockReset().mockReturnValue('/Users/alice') - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('registers the repos:create handler', () => { diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 6d200522535..6e4de0adbdd 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -54,7 +54,7 @@ describe('projectGroups IPC validation', () => { mockWindow.webContents.send.mockReset() resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('rejects malformed local project group create arguments before persistence', () => { diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 305a900f711..0ff8b48c773 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -55,7 +55,7 @@ describe('repos:add + repos:clone', () => { resetLocalRepoMocks(reposMocks) mockWindow.webContents.send.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => { diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index 395bfec8001..4bd7e4f45a3 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -73,7 +73,7 @@ describe('repos:add + repos:clone', () => { resetLocalRepoMocks(reposMocks) mockWindow.webContents.send.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) afterEach(async () => { diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index 010be624a91..8bb62d42fbc 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -59,7 +59,7 @@ describe('projectGroups IPC validation', () => { mockWindow.webContents.send.mockReset() resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('uses completed scan ids as an allowlist for nested imports', async () => { diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 26650246a06..53d4e64477f 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -52,7 +52,7 @@ describe('projectGroups IPC validation', () => { mockWindow.webContents.send.mockReset() resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('scans nested repositories over a connected SSH filesystem', async () => { diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 0e843f31e95..14b8bbd0b68 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -80,7 +80,7 @@ describe('repos folder pickers', () => { removeHandlerMock.mockReset() showOpenDialogMock.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('registers the multi-folder picker with handler cleanup', () => { diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 8ee9cb1cfc8..f4a43633712 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -51,7 +51,7 @@ describe('repos:getBaseRefDefault envelope', () => { prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) // Reset exec so a newly added test doesn't inherit the previous test's exec mock. mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('returns { defaultBaseRef, remoteCount: 0 } for folder-mode repos', async () => { @@ -235,7 +235,7 @@ describe('repos:searchBaseRefs SSH relay', () => { mockStore.getRepo.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('returns [] for a folder-mode repo without invoking the relay', async () => { diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index 8d8064f49d7..af8bf4ecf28 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -48,7 +48,7 @@ const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } async function registerHandlersWithoutNotifier(): Promise { vi.resetModules() const repos = await import('./repos') - repos.registerRepoHandlers(mainWindow as never, mockStore as never) + repos.registerRepoHandlers(mainWindow as never, mockStore as never, {} as never) return import('./repos/repos-changed-notification') } diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index d3f22254fd7..41d254fc16e 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -50,7 +50,7 @@ describe('repos:getGitUsername', () => { mockWindow.webContents.send.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('uses explicit SSH username config instead of remote author identity', async () => { diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index e5660eb8d27..3e18624828b 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -98,7 +98,7 @@ describe('repos:addRemote', () => { }) mockWindow.webContents.send.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never) + registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) it('registers the repos:addRemote handler', () => { diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 8de69f63255..5f7e7202e73 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -96,7 +96,7 @@ describe('sparse preset repo IPC handlers', () => { mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset) mockStore.removeSparsePreset.mockReset() - registerRepoHandlers(mainWindow as never, mockStore as never) + registerRepoHandlers(mainWindow as never, mockStore as never, {} as never) }) it('normalizes and de-duplicates saved sparse preset directories', () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 56e00fac46f..3c2754507b7 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -13,8 +13,13 @@ import { registerRepoFolderPickerHandlers } from './repos/repo-folder-picker-han import { registerRepoCloneHandlers } from './repos/repo-clone-lifecycle' import { registerRepoGitUsernameHandler } from './repos/repo-git-username-handler' import { registerBaseRefQueryHandlers } from './repos/base-ref-query-handlers' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' -export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerRepoHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: OrcaRuntimeService +): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:listForExecutionHost') @@ -67,7 +72,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v registerProjectHostSetupHandlers(mainWindow, store) registerRepoCreationHandlers(mainWindow, store) registerProjectGroupHandlers(mainWindow, store) - registerFolderWorkspaceHandlers(mainWindow, store) + registerFolderWorkspaceHandlers(mainWindow, store, runtime) registerNestedRepoImportHandler(mainWindow, store) registerRepoUpdateHandler(mainWindow, store) registerSparsePresetHandlers(mainWindow, store) diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 3bd8caa5fd1..2c2968a3c9a 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -9,6 +9,7 @@ import { getFolderWorkspacePathStatusForPath } from '../../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { notifyReposChanged } from './repos-changed-notification' import { FolderWorkspaceCreateArgs, @@ -18,7 +19,11 @@ import { parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' -export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerFolderWorkspaceHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: OrcaRuntimeService +): void { ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces()) ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => { @@ -107,16 +112,13 @@ export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store } ) - ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => { + ipcMain.handle('folderWorkspaces:delete', async (_event, rawArgs: unknown): Promise => { const args = parseProjectGroupIpcArgs( FolderWorkspaceSelectorArgs, rawArgs, 'invalid_folder_workspace_delete_args' ) - const deleted = store.removeFolderWorkspace(args.folderWorkspaceId) - if (deleted) { - notifyReposChanged(mainWindow) - } - return deleted + // Why: the runtime owns PTY/browser/session teardown and notifies on success. + return (await runtime.deleteFolderWorkspace(args.folderWorkspaceId)).deleted }) } diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 8c3bf4bbc6d..93fd6ff0e33 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import type { SshTarget } from '../../shared/ssh-types' +import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' @@ -60,14 +60,21 @@ async function doResetRelay(targetId: string, target: SshTarget): Promise assertSshConnectsNotFenced() conn = await connectionManager!.connect(target) } + let relayStopAcknowledged = false try { await forceStopRelayForTarget(conn, targetId) + relayStopAcknowledged = true } finally { const ptyIds = new Set(getPtyIdsForConnection(targetId)) for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { if (lease.state !== 'terminated' && lease.state !== 'expired') { ptyIds.add(lease.ptyId) - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + // Why: only a host-acknowledged force-stop may retire a lease. When it threw we never + // observed those shells, so expiring them would record a verdict we do not hold; mirrors + // ssh:terminateSessions, and the next connect re-attaches (or expires) them on evidence. + if (relayStopAcknowledged) { + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + } } } // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. @@ -98,6 +105,9 @@ export function registerSshConnectionHandlers(): void { ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { invalidateConnectAttempt(args.targetId) + // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell + // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. + let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } await runTargetLifecycle(args.targetId, async () => { const provider = getSshPtyProvider(args.targetId) const leases = persistedStore!.getSshRemotePtyLeases(args.targetId) @@ -142,6 +152,10 @@ export function registerSshConnectionHandlers(): void { ) ) : [] + if (!provider) { + // Nothing observed these remote shells, so their state is unknown — not "nothing to do". + outcome = { terminated: 0, unverifiable: ptyIds.length } + } const shutdownFailures: string[] = [] for (const [index, result] of shutdownResults.entries()) { const { appPtyId, relayPtyId } = ptyIds[index] @@ -154,6 +168,7 @@ export function registerSshConnectionHandlers(): void { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + outcome = { ...outcome, terminated: outcome.terminated + 1 } } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. @@ -161,6 +176,7 @@ export function registerSshConnectionHandlers(): void { } await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist()) }) + return outcome }) ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { diff --git a/src/main/ipc/ssh-relay-reset-resume.test.ts b/src/main/ipc/ssh-relay-reset-resume.test.ts index 9051cac5a5c..36ad2090435 100644 --- a/src/main/ipc/ssh-relay-reset-resume.test.ts +++ b/src/main/ipc/ssh-relay-reset-resume.test.ts @@ -77,6 +77,38 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) + // A force-stop that threw observed nothing about the remote shells, so expiring their leases + // would record a verdict Orca never obtained (docs/reference/ssh-execution-boundary.md). + it('ssh:resetRelay keeps leases alive when the force-stop never reported a result', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(undefined) + mockStore.getSshRemotePtyLeases.mockReturnValue([ + { targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }, + { targetId: 'ssh-1', ptyId: 'pty-2', state: 'attached' } + ]) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('channel closed')) + + await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow( + 'channel closed' + ) + + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() + // The local handles are still stale — only the host-side verdict is withheld. + expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-1') + expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') + expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + }) + it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => { const target: SshTarget = { id: 'ssh-1', diff --git a/src/main/ipc/ssh-terminate-sessions.test.ts b/src/main/ipc/ssh-terminate-sessions.test.ts index 77a8638b098..ccdc19bbe34 100644 --- a/src/main/ipc/ssh-terminate-sessions.test.ts +++ b/src/main/ipc/ssh-terminate-sessions.test.ts @@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => { mockPtyProvider.shutdown.mockResolvedValue(undefined) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', { immediate: true, @@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => { await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) }) - it('ssh:terminateSessions cannot reach expired leases without a relay', async () => { + // Issue #12661: an offline sweep tears down local transport only. Reporting plain success would + // read as "the remote shells are gone" when nobody asked the host. + it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => { mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } ]) @@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => { await expect( handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toBeUndefined() + ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() + // Still no forced reconnect: an expired lease can name a host that is gone for good (#2626). expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'ssh-1', + 'pty-expired', + 'terminated' + ) + }) + + it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => { + mockStore.getSshRemotePtyLeases.mockReturnValue([]) + vi.mocked(getSshPtyProvider).mockReturnValue(undefined) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).resolves.toEqual({ terminated: 0, unverifiable: 0 }) }) it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => { diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 0b736acd482..eacd2cd133f 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -108,8 +108,13 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { exec ) expect(removeCalls(exec)).toEqual([]) - // No probing at all when we won't act. - expect(exec).not.toHaveBeenCalled() + // Why: the store flag alone can't rule out ownership -- on-demand + // materialization (#17828) never sets it, so cleanup also probes the + // repo-local `orca-created` config provenance before bailing. + expect(exec).toHaveBeenCalledWith( + ['config', '--get', `remote.${FORK_REMOTE}.orca-created`], + REPO_PATH + ) }) it('never touches origin or upstream', async () => { @@ -242,6 +247,20 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { expect(removeCalls(exec)).toEqual([]) }) + it('removes a remote owned only via git-config provenance (lazily materialized, #17828)', async () => { + // Why: on-demand materialization never sets the store's `remoteCreated` + // flag, so ownership must also be provable from `remote..orca-created`. + const exec = makeExec({ branchConfig: 'true' }) + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget({ remoteCreated: false }), + storeOf({ 'repo-1::/wt/a': forkTarget({ remoteCreated: false }) }), + exec + ) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + it('does nothing when the remote is already gone (get-url throws)', async () => { const exec = makeExec({ getUrlThrows: true }) await cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 9bf29f718b2..09918ffe8f7 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -16,7 +16,11 @@ export type GitRemoteExec = ( args: string[], cwd: string ) => Promise<{ stdout: string; stderr?: string }> -export type WorktreePushTargetStore = Pick +// Why: `setWorktreeMeta` is optional so existing narrow test stubs (only +// `getAllWorktreeMeta`) keep compiling; callers that want materialize-time +// provenance persistence (worktree-remote.ts) pass a store that has it. +export type WorktreePushTargetStore = Pick & + Partial> export function sameGitHubRemoteUrl(left: string, right: string): boolean { if (left === right) { @@ -181,6 +185,26 @@ function isBranchConfigSeparator(code: number): boolean { return code === 32 || (code >= 9 && code <= 13) } +// Why: on-demand materialization (push/pull/fetch/fast-forward, #17828) never +// updates the store's `pushTarget.remoteCreated` flag, so ownership must also be +// readable from the repo-local `remote..orca-created` config Orca writes +// when it creates the remote (see `worktree-push-target-setup.ts`). +async function remoteHasOrcaProvenance( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string +): Promise { + try { + const { stdout } = await execGit( + ['config', '--get', `remote.${remoteName}.orca-created`], + repoPath + ) + return stdout.trim() === 'true' + } catch { + return false + } +} + // Exported for unit tests: the `execGit` seam lets tests drive the multi-fork // cleanup matrix without touching a real repo. export async function cleanupUnusedWorktreePushTargetRemoteWithExec( @@ -190,11 +214,12 @@ export async function cleanupUnusedWorktreePushTargetRemoteWithExec( store: WorktreePushTargetStore, execGit: GitRemoteExec ): Promise { + if (!target?.remoteUrl || target.remoteName === 'origin' || target.remoteName === 'upstream') { + return + } if ( - !target?.remoteCreated || - !target.remoteUrl || - target.remoteName === 'origin' || - target.remoteName === 'upstream' + !target.remoteCreated && + !(await remoteHasOrcaProvenance(execGit, repoPath, target.remoteName)) ) { return } diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts index e59da7bdfe7..b28ae36b6f4 100644 --- a/src/main/ipc/worktree-push-target-reconciliation.ts +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -13,7 +13,7 @@ import { listWorktrees } from '../git/worktree' import type { SshGitProvider } from '../providers/ssh-git-provider' import type { GitPushTarget } from '../../shared/worktree/types' import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id' -import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import { parseGitRemoteFetchUrls } from '../../shared/git-remote-url-index' import { findWorktreeMetaReferencingRemote, hasBranchConfigUsingRemote, @@ -44,26 +44,9 @@ async function listPrRemoteCandidates( } catch { return [] } - const candidates = new Map() - for (const line of iterateProcessOutputLines(stdout)) { - const parsed = parseRemoteVerboseLine(line) - if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) { - candidates.set(parsed.name, parsed.url) - } - } - return [...candidates.entries()].map(([name, url]) => ({ name, url })) -} - -function parseRemoteVerboseLine( - line: string -): { name: string; url: string; direction: 'fetch' | 'push' } | null { - const tabIndex = line.indexOf('\t') - if (tabIndex === -1) { - return null - } - const name = line.slice(0, tabIndex) - const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim()) - return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null + return [...parseGitRemoteFetchUrls(stdout)] + .filter(([name]) => isOrcaGeneratedPrRemoteName(name)) + .map(([name, url]) => ({ name, url })) } async function shouldReclaimPrRemote( diff --git a/src/main/ipc/worktree-push-target-remote-scan.test.ts b/src/main/ipc/worktree-push-target-remote-scan.test.ts new file mode 100644 index 00000000000..f2392c5e71a --- /dev/null +++ b/src/main/ipc/worktree-push-target-remote-scan.test.ts @@ -0,0 +1,178 @@ +// Why: `findRemoteForUrl` used to run `git remote` and then one serial +// `git remote get-url` per remote. These tests pin both halves of the fix: the +// subprocess count at 58 remotes, and result-for-result parity with the old scan +// across the remote shapes a real repo produces. + +import { describe, expect, it } from 'vitest' +import { parseGitHubOwnerRepo } from '../github/gh-utils' +import { findRemoteForUrl } from './worktree-push-target-setup' +import type { GitRemoteExec } from './worktree-push-target-cleanup' + +const SSH_FORK = 'git@github.com:contributor/orca.git' +const HTTPS_FORK = 'https://github.com/contributor/orca.git' +const GITLAB_FORK = 'https://gitlab.com/contributor/orca.git' +const UPSTREAM = 'https://github.com/stablyai/orca.git' + +type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string } + +type CountingExec = GitRemoteExec & { spawns: string[][] } + +function makeExec(remotes: readonly RemoteRow[]): CountingExec { + const spawns: string[][] = [] + const exec: GitRemoteExec = async (args: string[]) => { + spawns.push(args) + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `${remotes.map((remote) => remote.name).join('\n')}\n` } + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: remotes + .flatMap((remote) => [ + `${remote.name}\t${remote.fetchUrl} (fetch)`, + `${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)` + ]) + .join('\n') + } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + const match = remotes.find((remote) => remote.name === args[2]) + if (!match) { + throw new Error(`No such remote ${args[2]}`) + } + return { stdout: `${match.fetchUrl}\n` } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + } + return Object.assign(exec, { spawns }) +} + +/** The pre-fix scan, kept as the oracle the batched form must reproduce exactly. */ +async function findRemoteForUrlPerRemote( + execGit: GitRemoteExec, + repoPath: string, + remoteUrl: string +): Promise { + const target = parseGitHubOwnerRepo(remoteUrl) + try { + const { stdout } = await execGit(['remote'], repoPath) + for (const remote of stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + try { + const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) + const candidateUrl = urlStdout.trim() + const candidate = parseGitHubOwnerRepo(candidateUrl) + if ( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) { + return remote + } + if (candidateUrl === remoteUrl) { + return remote + } + } catch { + // Ignore a remote that disappeared or has no fetch URL. + } + } + } catch { + return null + } + return null +} + +const fiftyEightRemotes: RemoteRow[] = [ + { name: 'origin', fetchUrl: UPSTREAM }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + fetchUrl: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-contributor-orca', fetchUrl: SSH_FORK } +] + +const matrix: { name: string; remotes: RemoteRow[]; lookupUrl: string }[] = [ + { name: 'no remotes', remotes: [], lookupUrl: SSH_FORK }, + { + name: 'one matching remote', + remotes: [{ name: 'origin', fetchUrl: SSH_FORK }], + lookupUrl: SSH_FORK + }, + { + name: 'one non-matching remote', + remotes: [{ name: 'origin', fetchUrl: UPSTREAM }], + lookupUrl: SSH_FORK + }, + { name: '58 remotes, match last', remotes: fiftyEightRemotes, lookupUrl: SSH_FORK }, + { + name: '58 remotes, no match', + remotes: fiftyEightRemotes, + lookupUrl: 'https://github.com/nobody/other.git' + }, + { + name: 'duplicate URLs on two remotes', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork-a', fetchUrl: SSH_FORK }, + { name: 'fork-b', fetchUrl: SSH_FORK } + ], + lookupUrl: SSH_FORK + }, + { + name: 'fetch and push URLs differ', + remotes: [{ name: 'split', fetchUrl: SSH_FORK, pushUrl: HTTPS_FORK }], + lookupUrl: SSH_FORK + }, + { + name: 'SSH-form lookup against an HTTPS-form remote', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork', fetchUrl: HTTPS_FORK } + ], + lookupUrl: SSH_FORK + }, + { + name: 'HTTPS-form lookup against an SSH-form remote', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork', fetchUrl: SSH_FORK } + ], + lookupUrl: HTTPS_FORK + }, + { + name: 'non-GitHub provider matches only on the exact URL', + remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }], + lookupUrl: GITLAB_FORK + }, + { + name: 'non-GitHub provider with a different host does not match', + remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }], + lookupUrl: 'https://bitbucket.org/contributor/orca.git' + } +] + +describe('findRemoteForUrl', () => { + it.each(matrix)('matches the per-remote scan for $name', async ({ remotes, lookupUrl }) => { + const expected = await findRemoteForUrlPerRemote(makeExec(remotes), '/repo', lookupUrl) + await expect(findRemoteForUrl(makeExec(remotes), '/repo', lookupUrl)).resolves.toBe(expected) + }) + + it('answers from one subprocess at 58 remotes instead of one per remote', async () => { + const legacyExec = makeExec(fiftyEightRemotes) + await findRemoteForUrlPerRemote(legacyExec, '/repo', 'https://github.com/nobody/other.git') + expect(legacyExec.spawns).toHaveLength(fiftyEightRemotes.length + 1) + + const exec = makeExec(fiftyEightRemotes) + await findRemoteForUrl(exec, '/repo', 'https://github.com/nobody/other.git') + expect(exec.spawns).toEqual([['remote', '-v']]) + }) + + it('returns null when the remote table cannot be read', async () => { + const failing: GitRemoteExec = async () => { + throw new Error('not a git repository') + } + await expect(findRemoteForUrl(failing, '/repo', SSH_FORK)).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index be718cfd10c..ef1e44aa923 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -5,7 +5,9 @@ import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, - prepareWorktreePushTargetWithExec + prepareWorktreePushTargetWithExec, + remoteAlreadyMatchesUrl, + restoreUpstreamAfterMaterialize } from './worktree-push-target-setup' type ExecMock = Mock @@ -14,13 +16,31 @@ const REPO = '/repo-root' const FORK_SSH = 'git@github.com:contributor/orca.git' const FORK_HTTPS = 'https://github.com/contributor/orca.git' +/** Real `git remote -v` shape: a fetch row and a push row per remote, tab-separated. */ +export function renderRemoteVerbose(remotes: Record): string { + return Object.entries(remotes) + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + // A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so -// later lookups see the new remote, matching real git behavior. -function makeRepoExec(remotes: Record): ExecMock { +// later lookups see the new remote, matching real git behavior. Defaults +// `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD +// always resolves to one (mirrors real git, unlike an empty-stdout stub). +function makeRepoExec( + remotes: Record, + checkedOutBranch = 'local-branch' +): ExecMock { return vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref' && args[1] === '--short' && args[2] === 'HEAD') { + return { stdout: `${checkedOutBranch}\n`, stderr: '' } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: Object.keys(remotes).join('\n'), stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v' && args.length === 2) { + return { stdout: renderRemoteVerbose(remotes), stderr: '' } + } if (args[0] === 'remote' && args[1] === 'get-url') { const url = remotes[args[2]!] if (!url) { @@ -80,6 +100,29 @@ describe('prepareWorktreePushTargetWithExec', () => { }) }) + it('records repo-local provenance on the remote it adds (#17828)', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + + await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + // Why: cleanup's ownership check must survive a store purge (worktree-push-target-cleanup.ts). + // Narrowing the refspec (#17887) also writes `config` calls, so scope to the marker itself. + expect(callsMatching(exec, ['config', 'remote.pr-contributor-orca.orca-created'])).toEqual([ + ['config', 'remote.pr-contributor-orca.orca-created', 'true'] + ]) + }) + + it('does not record provenance when reusing an existing remote', async () => { + const exec = makeRepoExec({ + origin: 'git@github.com:stablyai/orca.git', + 'pr-contributor-orca': FORK_HTTPS + }) + + await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + expect(callsMatching(exec, ['config', 'remote.pr-contributor-orca.orca-created'])).toEqual([]) + }) + it('reuses an existing remote pointing at the same fork (SSH vs HTTPS) without adding', async () => { const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git', @@ -158,6 +201,38 @@ describe('findRemoteForUrl', () => { }) }) +describe('remoteAlreadyMatchesUrl', () => { + it('matches an exact URL', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': FORK_SSH }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(true) + }) + + it('matches by GitHub owner/repo across URL protocols', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': FORK_HTTPS }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(true) + }) + + it('returns false when the named remote points elsewhere', async () => { + const exec = makeRepoExec({ + 'pr-contributor-orca': 'git@github.com:someone-else/orca.git' + }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(false) + }) + + it('returns false when the named remote does not exist', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(false) + }) +}) + describe('ensureUniqueRemoteName', () => { it('returns the preferred name when it is free', async () => { const exec = makeRepoExec({ origin: 'x' }) @@ -190,6 +265,46 @@ describe('configureCreatedWorktreePushTargetWithExec', () => { }) }) +describe('restoreUpstreamAfterMaterialize', () => { + it('points the checked-out branch upstream at the fork remote', async () => { + const exec = makeRepoExec({}, 'local-branch') + const target = forkTarget() + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(exec).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/fix', 'local-branch'], + '/wt/path' + ) + expect(result).toBe(target) + }) + + it('is a no-op when the target has no remoteUrl', async () => { + const exec = makeRepoExec({}, 'local-branch') + const target: GitPushTarget = { remoteName: 'origin', branchName: 'feature' } + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(callsMatching(exec, ['branch', '--set-upstream-to'])).toEqual([]) + expect(result).toBe(target) + }) + + it('is a no-op when HEAD is detached (no checked-out branch)', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + throw new Error('fatal: ref HEAD is not a symbolic ref') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(callsMatching(exec, ['branch', '--set-upstream-to'])).toEqual([]) + expect(result).toBe(target) + }) +}) + describe('prepareWorktreePushTargetWithExec rollback', () => { it('removes the remote it just added when the fetch fails', async () => { const remotes: Record = { origin: 'git@github.com:stablyai/orca.git' } diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index e064b1f35c3..c4a82d94933 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -5,48 +5,65 @@ // repo. The store-aware ownership decision stays with the caller via a predicate. import type { GitPushTarget } from '../../shared/worktree/types' -import { parseGitHubOwnerRepo } from '../github/gh-utils' -import type { GitRemoteExec } from './worktree-push-target-cleanup' +import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' +import { sameGitHubRemoteUrl, type GitRemoteExec } from './worktree-push-target-cleanup' import { buildNarrowForkFetchRefspec, ensureRemoteTracksBranchNarrowly } from '../git/fork-remote-refspec' +// One `git remote -v` replaces `git remote` plus a serial `git remote get-url` per +// remote -- 59 subprocesses at 58 remotes, on every push-target resolution (#17914). export async function findRemoteForUrl( execGit: GitRemoteExec, repoPath: string, remoteUrl: string ): Promise { - const target = parseGitHubOwnerRepo(remoteUrl) try { - const { stdout } = await execGit(['remote'], repoPath) - for (const remote of stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean)) { - try { - const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) - const candidateUrl = urlStdout.trim() - const candidate = parseGitHubOwnerRepo(candidateUrl) - if ( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) { - return remote - } - if (candidateUrl === remoteUrl) { - return remote - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await execGit(['remote', '-v'], repoPath) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => + sameGitHubRemoteUrl(candidateUrl, remoteUrl) + ) } catch { return null } - return null +} + +// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward): +// a single `remote get-url ` skips the whole-remote-table read once a fork +// remote already exists under its expected name (#17828). +export async function remoteAlreadyMatchesUrl( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string, + remoteUrl: string +): Promise { + try { + const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath) + return sameGitHubRemoteUrl(stdout.trim(), remoteUrl) + } catch { + return false + } +} + +// Why (#17828 CodeRabbit follow-up): a deferred remote materialized after create +// (terminal spawn, push/pull/fetch) must restore the upstream link create used to +// configure, or raw `git pull`/`git log @{u}..` keep failing even once the remote +// exists. The checked-out branch is resolved fresh rather than threaded through +// every materialize call site, since `target.branchName` is the fork's PR head ref +// and can differ from the worktree's local branch name (rename-on-collision). +export async function resolveCheckedOutBranchName( + execGit: GitRemoteExec, + repoPath: string +): Promise { + try { + const { stdout } = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath) + const branch = stdout.trim() + return branch.length > 0 ? branch : null + } catch { + // Detached HEAD or an unreadable ref -- nothing to point upstream. + return null + } } export async function ensureUniqueRemoteName( @@ -103,16 +120,26 @@ export async function prepareWorktreePushTargetWithExec( remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName) // Why: `-t --no-tags` means this remote is never, even transiently, // written with the wide default `refs/heads/*` refspec + tag auto-follow (#17828). - // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately - // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` - // (see that function's comment for why the suffix matters). await execGit( ['remote', 'add', '-t', target.branchName, '--no-tags', remoteName, target.remoteUrl], repoPath ) - await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) - remoteCreated = true remoteAddedHere = true + try { + // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately + // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` + // (see that function's comment for why the suffix matters). + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) + // Why: repo-local provenance that survives a store purge and is removed + // atomically with the remote itself, unlike the store's `remoteCreated` flag. + await execGit(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) + } catch (error) { + // Why: a half-configured remote with no provenance marker is unreclaimable -- + // cleanup only runs off that marker, so a failure here must undo the add. + await execGit(['remote', 'remove', remoteName], repoPath).catch(() => {}) + throw error + } + remoteCreated = true } } @@ -138,6 +165,31 @@ export async function prepareWorktreePushTargetWithExec( } } +// Why (#17828 CodeRabbit follow-up, restructured per review): materializing the remote +// alone isn't enough -- raw `git pull`/`git push`/`git log @{u}..` still fail without the +// upstream link create-time configuration used to set up. This must run at the *materializer* +// level (called by both the short-circuit and full-prepare paths in worktree-remote.ts), not +// buried inside `prepare*`, or every call after the first materialize -- and any sibling +// worktree that reuses the same fork remote -- never reaches it. Unconditional (not just +// "newly added") because a reused remote's upstream for *this* worktree's branch isn't +// guaranteed set. The checked-out branch is resolved fresh rather than threaded through +// every materialize call site, since `target.branchName` is the fork's PR head ref and can +// differ from the worktree's local branch name (rename-on-collision). +export async function restoreUpstreamAfterMaterialize( + execGit: GitRemoteExec, + worktreePath: string, + target: GitPushTarget +): Promise { + if (!target.remoteUrl) { + return target + } + const checkedOutBranch = await resolveCheckedOutBranchName(execGit, worktreePath) + if (!checkedOutBranch) { + return target + } + return configureCreatedWorktreePushTargetWithExec(execGit, worktreePath, checkedOutBranch, target) +} + export async function configureCreatedWorktreePushTargetWithExec( execGit: GitRemoteExec, worktreePath: string, diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts new file mode 100644 index 00000000000..695baa10dec --- /dev/null +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -0,0 +1,604 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { WorktreePushTargetStore } from './worktree-push-target-cleanup' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from './worktree-remote' + +const REPO_PATH = '/repo-root' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + ...overrides + } +} + +describe('materializeWorktreePushTargetRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('is a no-op when the target already reports remoteCreated', async () => { + const target = forkTarget({ remoteCreated: true }) + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a same-repo target with no remoteUrl', async () => { + const target = forkTarget({ remoteUrl: undefined }) + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('short-circuits the remote probe but still restores upstream and widens the refspec', async () => { + // Why (#17828 review follow-up): the short-circuit is the common case for every call + // after the first, and for a sibling worktree reusing the same fork remote under a + // different branch -- it must still restore the upstream link and widen the refspec. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['remote', 'get-url', FORK_REMOTE]) + expect(calls).toContainEqual([ + 'config', + '--add', + `remote.${FORK_REMOTE}.fetch`, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ]) + expect(calls).toContainEqual(['config', `remote.${FORK_REMOTE}.tagOpt`, '--no-tags']) + expect(calls).toContainEqual(['symbolic-ref', '--short', 'HEAD']) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + }) + + it('materializes the remote (add + provenance + fetch) when the probe misses', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toEqual({ ...target, remoteCreated: true }) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + // Mint uses the narrow `-t --no-tags` add form (#17887), not a bare `remote add`. + expect(calls).toContainEqual([ + 'remote', + 'add', + '-t', + target.branchName, + '--no-tags', + FORK_REMOTE, + FORK_URL + ]) + expect(calls).toContainEqual(['config', `remote.${FORK_REMOTE}.orca-created`, 'true']) + expect(calls).toContainEqual([ + 'fetch', + FORK_REMOTE, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ]) + }) + + it('fetches the missing tracking ref before restoring upstream on the short-circuit path (#17828 sibling worktree)', async () => { + // Why: a sibling worktree short-circuiting onto an already-existing remote under a + // *new* branch has a widened refspec but no tracking ref yet -- against real git, + // `branch --set-upstream-to` hard-fails with "the requested upstream branch does not + // exist" unless something fetches that branch first. Verified against a real git + // fixture, not just this mock (see PR discussion). + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'rev-parse') { + throw new Error('unknown revision') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'fetch' + ) + expect(fetchCalls).toEqual([ + [ + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ], + expect.objectContaining({ timeout: expect.any(Number) }) + ] + ]) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'rev-parse', + '--verify', + '--quiet', + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + }) + + it('skips the fetch when the tracking ref already exists on the short-circuit path', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + // rev-parse succeeds by default (ref already exists) -- no fetch should follow. + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + await materializeWorktreePushTargetRemote(REPO_PATH, target) + + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'fetch' + ) + expect(fetchCalls).toEqual([]) + }) + + it("gives a joiner its own branch wiring instead of the minting sibling's target", async () => { + // Why (#17828 review): the single flight is keyed on the remote, but the refspec widen, + // tracking-ref fetch and upstream link are all per-branch. A sibling worktree joining an + // in-flight mint for a *different* branch previously received the minter's target and + // skipped all three, leaving its own branch with no upstream. + let remoteExists = false + let releaseAdd!: () => void + const addGate = new Promise((resolve) => { + releaseAdd = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + if (!remoteExists) { + throw new Error('No such remote') + } + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'add') { + await addGate + remoteExists = true + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'joiner/branch\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + const minter = materializeWorktreePushTargetRemote(REPO_PATH, forkTarget()) + await Promise.resolve() + const joiner = materializeWorktreePushTargetRemote( + REPO_PATH, + forkTarget({ branchName: 'joiner/branch' }) + ) + releaseAdd() + const [, joined] = await Promise.all([minter, joiner]) + + // The joiner keeps its own branch rather than inheriting the minter's. + expect(joined.branchName).toBe('joiner/branch') + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/joiner/branch`, + 'joiner/branch' + ]) + // Exactly one mint: the joiner must not have raced a second `remote add`. + expect(calls.filter((call) => call[0] === 'remote' && call[1] === 'add')).toHaveLength(1) + }) + + it('propagates a failed mint instead of adopting a remote the rollback removed', async () => { + // Why (#17828 review): both mint rollbacks `remote remove`, so adopting after a failed mint + // writes `remote..fetch` with no URL -- a config-only ghost that breaks + // `git fetch --all`, forces later mints to a `-2` name, and survives `git remote remove`. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === 'add') { + throw new Error('mint failed') + } + return { stdout: '', stderr: '' } + }) + + const minter = materializeWorktreePushTargetRemote(REPO_PATH, forkTarget()) + await Promise.resolve() + const joiner = materializeWorktreePushTargetRemote( + REPO_PATH, + forkTarget({ branchName: 'joiner/branch' }) + ) + + await expect(minter).rejects.toThrow() + await expect(joiner).rejects.toThrow() + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + // No ghost: nothing wrote refspec or tagOpt config for a remote that does not exist. + expect( + calls.filter((call) => call[0] === 'config' && String(call[2] ?? '').includes(FORK_REMOTE)) + ).toHaveLength(0) + }) + + it('persists remoteCreated to the store when a worktreeId is provided and the mint succeeds', async () => { + // Why (#17828 review follow-up): on-demand materialization never went through the + // create-time setWorktreeMeta write, so a lazily-minted remote stayed invisible to + // #17842's orphan sweep (which gates solely on the stored remoteCreated flag). + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + const result = await materializeWorktreePushTargetRemote( + REPO_PATH, + target, + store, + undefined, + {}, + 'worktree-1' + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pushTarget: { ...target, remoteCreated: true } + }) + }) + + it('does not touch the store when no worktreeId is provided', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + await materializeWorktreePushTargetRemote(REPO_PATH, target, store) + + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) +}) + +describe('materializeWorktreePushTargetRemoteSsh', () => { + it('is a no-op when the target already reports remoteCreated', async () => { + const exec = vi.fn() + const target = forkTarget({ remoteCreated: true }) + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + expect(exec).not.toHaveBeenCalled() + }) + + it('short-circuits the remote probe but still restores upstream (refspec widening is a local-only gap)', async () => { + // Why: mirrors the local short-circuit's upstream restore. Refspec widening is + // intentionally NOT mirrored here -- SSH's bare `remote add` is a pre-existing, + // documented gap this fix does not touch. + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn() + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['remote', 'get-url', FORK_REMOTE]) + expect(calls).toContainEqual(['symbolic-ref', '--short', 'HEAD']) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + expect(calls.some((call) => call[0] === 'config' && String(call[2]).includes('.fetch'))).toBe( + false + ) + expect(fetchRemoteTrackingRef).not.toHaveBeenCalled() + }) + + it('fetches the missing tracking ref (one-off, no config write) before restoring upstream on the short-circuit path', async () => { + // SSH mirror of the local sibling-worktree fix: refspec widening stays out of scope + // here, but the branch must still be fetched once before `--set-upstream-to` can + // succeed for a branch this remote has never pulled in. + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'rev-parse') { + throw new Error('unknown revision') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + REPO_PATH, + FORK_REMOTE, + target.branchName, + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + // Still no config write -- the fetch is a one-off refspec argument, not a widen. + expect(calls.some((call) => call[0] === 'config' && String(call[2]).includes('.fetch'))).toBe( + false + ) + }) + + it('materializes the remote (add + provenance + fetch) when the probe misses', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['check-ref-format', '--branch', target.branchName]) + expect(calls).toContainEqual(['remote', 'add', FORK_REMOTE, FORK_URL]) + // Provenance is a narrow RPC, not exec: the relay's generic git.exec blocks config writes. + expect(markRemoteOrcaCreated).toHaveBeenCalledWith(REPO_PATH, FORK_REMOTE) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + REPO_PATH, + FORK_REMOTE, + target.branchName, + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ) + }) + + it('persists remoteCreated to the store when a worktreeId is provided and the mint succeeds', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target, + store, + undefined, + 'worktree-1' + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pushTarget: { ...target, remoteCreated: true } + }) + }) + + // Moved from worktrees-ssh-fork-push-target-remote.test.ts: this behavior lives in + // prepareWorktreePushTargetSsh (invoked here through the materialize wrapper, once + // the fast probe misses) and is unchanged -- it just no longer runs at create time. + it('names the relay upgrade when an older host still rejects the fork remote', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === 'add') { + throw new Error('Destructive git remote operations are not allowed via exec') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn() + const target = forkTarget() + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + ).rejects.toThrow('Reconnect to deploy the latest relay') + expect(fetchRemoteTrackingRef).not.toHaveBeenCalled() + }) + + it('drops the fork remote it just added when the SSH head fetch fails', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => { + throw new Error('network unreachable') + }) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target + ) + ).rejects.toThrow('network unreachable') + + expect(exec).toHaveBeenCalledWith(['remote', 'remove', FORK_REMOTE], REPO_PATH) + }) + + // Regression: the rollback must not fire on ownership inherited from a sibling + // worktree, deleting the remote that worktree is still pushing through. The probe + // misses under the *requested* remote name so this reaches prepareWorktreePushTargetSsh's + // own by-URL reuse scan, which finds the sibling's differently-named remote. + it('keeps a reused fork remote a sibling worktree owns when the SSH head fetch fails', async () => { + const SIBLING_REMOTE = 'pr-contributor-orca-existing' + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + if (args[2] === SIBLING_REMOTE) { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + `${SIBLING_REMOTE}\t${FORK_URL} (fetch)`, + `${SIBLING_REMOTE}\t${FORK_URL} (push)` + ].join('\n'), + stderr: '' + } + } + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => { + throw new Error('network unreachable') + }) + const target = forkTarget() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({ + 'repo::/repo-root-sibling': { + pushTarget: { + remoteName: SIBLING_REMOTE, + branchName: 'contributor/other', + remoteUrl: FORK_URL, + remoteCreated: true + } + } + }) + } as unknown as WorktreePushTargetStore + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target, + store + ) + ).rejects.toThrow('network unreachable') + + expect(exec).not.toHaveBeenCalledWith(['remote', 'remove', SIBLING_REMOTE], REPO_PATH) + expect(exec).not.toHaveBeenCalledWith( + ['remote', 'add', expect.anything(), expect.anything()], + REPO_PATH + ) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index ce6ee7b3394..0b985a0311f 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -112,8 +112,15 @@ import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, - prepareWorktreePushTargetWithExec + prepareWorktreePushTargetWithExec, + remoteAlreadyMatchesUrl, + restoreUpstreamAfterMaterialize } from './worktree-push-target-setup' +import { + buildNarrowForkFetchRefspec, + ensureRemoteTracksBranchNarrowly, + forkRemoteTrackingRefExists +} from '../git/fork-remote-refspec' import { migrateForkRemoteRefspecs } from './worktree-push-target-refspec-migration' import { isENOENT } from './filesystem-path-containment' import { @@ -166,9 +173,36 @@ const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 // Why: bound the fallback `git fetch origin` so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation forever. const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000 +// Why (#17828 CodeRabbit follow-up): the deferred materialize fetch runs off the main +// create path (terminal spawn, mid-session sync) with nothing else bounding it -- same +// STA-1292 hang risk as the create-time fallback above, so mirror its timeout. +const DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS = 60_000 const sshWorktreeCreateFetchInflight = new Map>() const sshWorktreeCreateFetchCompletedAt = new Map() const sshWorktreeCreateFetchQueueTail = new Map>() +// Why (#17828 CodeRabbit follow-up): a terminal spawn and an explicit sync action can +// both call materialize for the same worktree remote at once; without single-flighting, +// the loser's `remote add` races the winner's fetch and can strand a duplicate remote. +const worktreePushTargetMaterializeInflight = new Map>() +const sshWorktreePushTargetMaterializeInflight = new WeakMap< + SshGitProvider, + Map> +>() + +function worktreePushTargetMaterializeKey(repoPath: string, remoteName: string): string { + return `${repoPath}::${remoteName}` +} + +function getSshWorktreePushTargetMaterializeInflight( + provider: SshGitProvider +): Map> { + let inflight = sshWorktreePushTargetMaterializeInflight.get(provider) + if (!inflight) { + inflight = new Map() + sshWorktreePushTargetMaterializeInflight.set(provider, inflight) + } + return inflight +} const sshWorktreeCreateBasePlanInflight = new Map< string, Promise @@ -972,7 +1006,16 @@ export async function prepareWorktreePushTarget( ): Promise { await validateGitPushTarget(repoPath, target, gitOptions) const prepared = await prepareWorktreePushTargetWithExec( - (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + // Why: this is only ever reached via the deferred materialize path (#17828) -- bound + // just the network fetch so it can't hang indefinitely (see the timeout constant's + // comment). The other calls this makes (`remote`, `remote add`, `config`) are local-only + // and must stay untimed, matching every other local git call in this file. + (args, cwd) => + gitExecFileAsync(args, { + cwd, + ...gitOptions, + ...(args[0] === 'fetch' ? { timeout: DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS } : {}) + }), repoPath, target, (existingRemote) => @@ -993,6 +1036,170 @@ export async function prepareWorktreePushTarget( return prepared } +// Why: on-demand twin of `prepareWorktreePushTarget` for push/pull/fetch/ +// fast-forward (#17828) -- a deferred fork remote is materialized the first +// time it's needed. The cheap named-remote probe keeps every push after the +// first one down to a handful of extra subprocesses (probe, refspec-widen, +// upstream-restore) instead of repeating the O(remotes) scan +// `prepareWorktreePushTargetWithExec` does when it must add. +export async function materializeWorktreePushTargetRemote( + repoPath: string, + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string, + gitOptions: { wslDistro?: string } = {}, + worktreeId?: string +): Promise { + if (!target.remoteUrl || target.remoteCreated) { + return target + } + const execGit: GitRemoteExec = (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }) + if (await remoteAlreadyMatchesUrl(execGit, repoPath, target.remoteName, target.remoteUrl)) { + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingForkRemoteForBranch( + execGit, + repoPath, + target, + gitOptions, + store, + repoId, + worktreeId + ) + ) + } + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const existing = worktreePushTargetMaterializeInflight.get(key) + if (existing) { + // Why: the single flight is keyed on the *remote*, but everything after the remote add is + // per-branch. A joiner waiting on a sibling worktree's mint must not take that sibling's + // target -- it would inherit the sibling's branch and silently skip its own refspec widen, + // tracking-ref fetch, and upstream link. Wait for the remote, then do its own. + // + // Why not swallow the rejection: both mint rollbacks remove the remote, so adopting after a + // failed mint would write `remote..fetch` with no URL -- a config-only ghost that + // breaks `git fetch --all`, forces every later mint to a `-2` name, and survives + // `git remote remove`. Propagate instead; the map is already cleared, so a retry re-mints. + await existing + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingForkRemoteForBranch( + execGit, + repoPath, + target, + gitOptions, + store, + repoId, + worktreeId + ) + ) + } + const promise = prepareWorktreePushTarget(repoPath, target, store, repoId, gitOptions) + .then((prepared) => restoreUpstreamAfterMaterialize(execGit, repoPath, prepared)) + .then((prepared) => { + persistMaterializedPushTargetIfCreated(store, worktreeId, prepared) + return prepared + }) + .finally(() => { + if (worktreePushTargetMaterializeInflight.get(key) === promise) { + worktreePushTargetMaterializeInflight.delete(key) + } + }) + worktreePushTargetMaterializeInflight.set(key, promise) + return promise +} + +// Why: the remote already exists -- minted by an earlier call, by create, or by a sibling +// worktree. Everything left is per-branch, and it must run for *this* target: the refspec +// widen, the tracking-ref fetch, and the upstream link. Previously these only ran inside +// prepareWorktreePushTarget, unreachable once the remote was there. +async function adoptExistingForkRemoteForBranch( + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + gitOptions: { wslDistro?: string }, + store: WorktreePushTargetStore | undefined, + repoId: string | undefined, + worktreeId: string | undefined +): Promise { + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, target.remoteName, target.branchName) + // Why: widening only rewrites config -- it never imports anything. For a sibling worktree's + // first materialize of a *new* branch on an already-existing remote, the branch's tracking + // ref doesn't exist yet, and `--set-upstream-to` below hard-fails with "the requested + // upstream branch does not exist" (verified against real git). Skip the fetch when the ref + // is already there so a repeat push/pull materialize stays a local-only probe. + if ( + !(await forkRemoteTrackingRefExists(execGit, repoPath, target.remoteName, target.branchName)) + ) { + // Why: a network fetch, unlike the local-only probes above -- bound it the same as the + // full-mint path's fetch so it can't hang indefinitely. + await gitExecFileAsync( + [ + 'fetch', + target.remoteName, + buildNarrowForkFetchRefspec(target.remoteName, target.branchName) + ], + { cwd: repoPath, ...gitOptions, timeout: DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS } + ) + } + const restored = await restoreUpstreamAfterMaterialize(execGit, repoPath, target) + // Why: a remote another worktree minted is still Orca-owned. Without stamping ownership on + // the adopting worktree too, removing the minter leaves the survivor's metadata unowned and + // #17842's sweep -- which gates solely on `remoteCreated` -- can never reclaim the remote. + // Why derive: no caller supplies both -- IPC handlers pass a store with no repo id, runtime + // commands pass a repo id with no store -- so requiring both made this branch unreachable. + const ownerRepoId = repoId ?? (worktreeId ? getRepoIdFromWorktreeId(worktreeId) : undefined) + const owned = + store !== undefined && + ownerRepoId !== undefined && + isPushTargetRemoteCreatedByKnownWorktree(store, restored, ownerRepoId) + const adopted = owned ? { ...restored, remoteCreated: true } : restored + persistMaterializedPushTargetIfCreated(store, worktreeId, adopted) + return adopted +} + +// Why: the mint single flight only covers `remote add`. Every adopter afterwards writes +// `remote..fetch` and `.tagOpt`, and concurrent `git config --add` has no lock retry -- +// measured 135/160 failures at 8-way concurrency, plus duplicate refspecs when two adopts add +// the same value. Chain adopts per remote so they serialize instead of fanning out. +const forkRemoteAdoptionQueue = new Map>() + +function runForkRemoteAdoption( + repoPath: string, + target: GitPushTarget, + run: () => Promise +): Promise { + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const previous = forkRemoteAdoptionQueue.get(key) + const next = previous ? previous.then(run, run) : run() + const settled = next.then( + () => undefined, + () => undefined + ) + forkRemoteAdoptionQueue.set(key, settled) + void settled.finally(() => { + if (forkRemoteAdoptionQueue.get(key) === settled) { + forkRemoteAdoptionQueue.delete(key) + } + }) + return next +} + +// Why (review follow-up): on-demand materialization never went through the create-time +// `setWorktreeMeta` write, so the store's `pushTarget.remoteCreated` flag stayed stale +// forever for a lazily-minted remote -- invisible to #17842's orphan sweep +// (`shouldReclaimPrRemote` gates solely on that flag) and to any SSH host whose relay +// predates `markRemoteOrcaCreated` (no git-config marker either). `setWorktreeMeta` is +// optional on `WorktreePushTargetStore` so narrow test/reconciliation stores keep compiling. +function persistMaterializedPushTargetIfCreated( + store: WorktreePushTargetStore | undefined, + worktreeId: string | undefined, + target: GitPushTarget +): void { + if (!target.remoteCreated || !worktreeId || !store?.setWorktreeMeta) { + return + } + store.setWorktreeMeta(worktreeId, { pushTarget: target }) +} + function isPushTargetRemoteCreatedByKnownWorktree( store: WorktreePushTargetStore, target: GitPushTarget, @@ -1062,7 +1269,7 @@ export async function configureCreatedWorktreePushTarget( ) } -async function prepareWorktreePushTargetSsh( +export async function prepareWorktreePushTargetSsh( provider: SshGitProvider, repoPath: string, target: GitPushTarget, @@ -1106,8 +1313,18 @@ async function prepareWorktreePushTargetSsh( } throw error } - remoteCreated = true remoteAddedHere = true + try { + // Why: repo-local provenance mirroring the local path (worktree-push-target-setup.ts). + // A narrow RPC, not provider.exec: the relay's generic git.exec blocks all config writes. + await provider.markRemoteOrcaCreated(repoPath, remoteName) + } catch (error) { + // Why: a remote with no provenance marker is unreclaimable -- cleanup only + // runs off that marker, so a failure here must undo the add. + await provider.exec(['remote', 'remove', remoteName], repoPath).catch(() => {}) + throw error + } + remoteCreated = true } } try { @@ -1129,6 +1346,96 @@ async function prepareWorktreePushTargetSsh( return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) } } +// SSH twin of `adoptExistingForkRemoteForBranch`. Refspec widening is intentionally absent -- +// SSH's bare `remote add` (no `-t`/`--no-tags`) is a pre-existing, documented gap -- but the +// tracking ref must still exist before `--set-upstream-to` can succeed, and the upstream link +// must be made against *this* target's branch rather than a minting sibling's. +async function adoptExistingSshForkRemoteForBranch( + provider: SshGitProvider, + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + store: WorktreePushTargetStore | undefined, + worktreeId: string | undefined +): Promise { + if ( + !(await forkRemoteTrackingRefExists(execGit, repoPath, target.remoteName, target.branchName)) + ) { + await provider.fetchRemoteTrackingRef( + repoPath, + target.remoteName, + target.branchName, + `refs/remotes/${target.remoteName}/${target.branchName}` + ) + } + const restored = await restoreUpstreamAfterMaterialize(execGit, repoPath, target) + const ownerRepoId = worktreeId ? getRepoIdFromWorktreeId(worktreeId) : undefined + const owned = + store !== undefined && + ownerRepoId !== undefined && + isPushTargetRemoteCreatedByKnownWorktree(store, restored, ownerRepoId) + const adopted = owned ? { ...restored, remoteCreated: true } : restored + persistMaterializedPushTargetIfCreated(store, worktreeId, adopted) + return adopted +} + +// SSH twin of `materializeWorktreePushTargetRemote` -- the relay has no store +// access and trusts `pushTarget.remoteName` already exists, so a deferred fork +// remote must be materialized client-side before dispatching push/pull/fetch/ +// fast-forward over the mux (#17828). +export async function materializeWorktreePushTargetRemoteSsh( + provider: SshGitProvider, + repoPath: string, + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string, + worktreeId?: string +): Promise { + if (!target.remoteUrl || target.remoteCreated) { + return target + } + const execGit: GitRemoteExec = (args, cwd) => provider.exec(args, cwd) + if (await remoteAlreadyMatchesUrl(execGit, repoPath, target.remoteName, target.remoteUrl)) { + // Why (review follow-up): mirrors the local short-circuit's upstream restore. Refspec + // widening is intentionally NOT mirrored here -- SSH's bare `remote add` (no `-t`/ + // `--no-tags`, see prepareWorktreePushTargetSsh) is a pre-existing, documented gap this + // fix does not touch. + // + // The tracking ref itself, though, must still exist before `--set-upstream-to` below + // can succeed -- a reused remote's wide default refspec covers a future bare fetch, + // but imports nothing on its own. Fetch just this branch (a one-off refspec argument, + // not a config write) when it isn't already there; skip it otherwise so a repeat + // push/pull materialize stays a local-only probe with no relay round-trip. + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingSshForkRemoteForBranch(provider, execGit, repoPath, target, store, worktreeId) + ) + } + const inflight = getSshWorktreePushTargetMaterializeInflight(provider) + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const existing = inflight.get(key) + if (existing) { + // Why: same per-branch reasoning as the local twin -- a joiner must not inherit the + // minter's branch. Rejection propagates rather than adopting a remote the rollback removed. + await existing + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingSshForkRemoteForBranch(provider, execGit, repoPath, target, store, worktreeId) + ) + } + const promise = prepareWorktreePushTargetSsh(provider, repoPath, target, store, repoId) + .then((prepared) => restoreUpstreamAfterMaterialize(execGit, repoPath, prepared)) + .then((prepared) => { + persistMaterializedPushTargetIfCreated(store, worktreeId, prepared) + return prepared + }) + .finally(() => { + if (inflight.get(key) === promise) { + inflight.delete(key) + } + }) + inflight.set(key, promise) + return promise +} + export async function cleanupUnusedWorktreePushTargetRemoteSsh( provider: SshGitProvider, repoPath: string, @@ -1763,17 +2070,9 @@ export async function createRemoteWorktree( } } - let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { - // Why: fork-PR SSH worktrees need contributor-remote setup before create, else Push/Sync target origin. - preparedPushTarget = await prepareWorktreePushTargetSsh( - provider, - repo.path, - args.pushTarget, - store, - repo.id - ) - } + // Why: defer the remote add + fetch to first push/pull/fetch/fast-forward + // (#17828) instead of paying it at create time for a read-only review. + const preparedPushTarget: GitPushTarget | undefined = args.pushTarget try { await timing.time('git_worktree_add', async () => @@ -1854,8 +2153,10 @@ export async function createRemoteWorktree( const now = Date.now() // Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch. const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { + // Why: `--set-upstream-to` needs the remote to exist -- true for a same-repo + // target but not for a fork remote, which materializes lazily (#17828). + let configuredPushTarget: GitPushTarget | undefined = preparedPushTarget + if (preparedPushTarget && !preparedPushTarget.remoteUrl) { configuredPushTarget = await configureCreatedWorktreePushTargetWithExec( (args, cwd) => provider.exec(args, cwd), created.path, @@ -2369,20 +2670,9 @@ export async function createLocalWorktree( } emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) - let preparedPushTarget: GitPushTarget | undefined - const requestedPushTarget = args.pushTarget - if (requestedPushTarget) { - // Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry. - preparedPushTarget = await timing.time('prepare_push_target', () => - prepareWorktreePushTarget( - repo.path, - requestedPushTarget, - store, - repo.id, - localWorktreeGitOptions - ) - ) - } + // Why: defer the remote add + fetch to first push/pull/fetch/fast-forward + // (#17828) instead of paying it at create time for a read-only review. + const preparedPushTarget: GitPushTarget | undefined = args.pushTarget const suggestLocalBaseRefUpdate = !settings.refreshLocalBaseRefOnWorktreeCreate && @@ -2522,9 +2812,10 @@ export async function createLocalWorktree( await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName) } - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { - // Why: fork-PR review worktrees publish back to the PR author's branch; set upstream so Push/Sync use the contributor remote, not origin. + // Why: `--set-upstream-to` needs the remote to exist -- true for a same-repo + // target but not for a fork remote, which materializes lazily (#17828). + let configuredPushTarget: GitPushTarget | undefined = preparedPushTarget + if (preparedPushTarget && !preparedPushTarget.remoteUrl) { configuredPushTarget = await configureCreatedWorktreePushTarget( worktreePath, branchName, diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 934844de2e3..ac003b3a43e 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -427,7 +427,10 @@ describe('registerWorktreeHandlers', () => { }) }) - it('configures a PR push target during local create', async () => { + // Was "configures a PR push target during local create": create used to mint the + // fork remote up front. It now defers to first sync (#17828); the minting itself is + // covered by worktree-remote-push-target-materialization.test.ts. + it('defers the fork-PR remote during local create and persists the target unmaterialized', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -449,7 +452,7 @@ describe('registerWorktreeHandlers', () => { } }) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'remote', 'add', @@ -461,7 +464,7 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'fetch', 'pr-prateek-orca', @@ -469,7 +472,8 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + // Upstream can only be set once the remote exists, so it defers with the remote. + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'branch', '--set-upstream-to', @@ -478,20 +482,25 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/improve-dashboard' } ) + // Exact object, not objectContaining: `remoteCreated` must stay absent until + // something actually mints the remote. expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: expect.objectContaining({ + pushTarget: { remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git', - remoteCreated: true - }) + remoteUrl: 'git@github.com:prateek/orca.git' + } }) ) }) - it('keeps the Orca-created marker when a new worktree reuses an Orca-created fork remote', async () => { + // Was "keeps the Orca-created marker ...": create used to inherit the marker while + // minting. With minting deferred (#17828) create must not claim ownership it has not + // earned; marker inheritance now happens at materialization and is covered by + // worktree-push-target-setup.test.ts. + it('does not claim the Orca-created marker at create when a sibling worktree minted the fork remote', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -538,12 +547,11 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: expect.objectContaining({ + pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/new-fix', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - }) + remoteUrl: 'https://github.com/contributor/orca.git' + } }) ) }) diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index c952a6be9df..fe80219fa8f 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { validateGitExecArgs } from '../../relay/git-exec-validator' import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' +import type { SshGitProvider } from '../providers/ssh-git-provider' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -90,7 +92,10 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) - it('adds the fork remote for an SSH fork-PR worktree through git.exec', async () => { + // Was "adds the fork remote ... through git.exec": create used to mint the fork + // remote unconditionally. It now defers to first sync (#17828) -- split in two so + // each half stays true to a single claim: create stays a no-op, sync still mints. + it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -147,11 +152,13 @@ describe('registerWorktreeHandlers', () => { } }) - expect(exec).toHaveBeenCalledWith( + expect(exec).not.toHaveBeenCalledWith( ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], '/remote/repo' ) - expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + // fetchRemoteTrackingRef IS called once here, but for create's unrelated + // base-ref refresh (origin/main) -- not for the fork remote, which defers. + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( '/remote/repo', 'pr-contributor-orca', 'contributor/fix', @@ -163,201 +170,58 @@ describe('registerWorktreeHandlers', () => { pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true + remoteUrl: 'https://github.com/contributor/orca.git' } }) ) }) - it('names the relay upgrade when an older host still rejects the fork remote', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'add') { - throw new Error('Destructive git remote operations are not allowed via exec') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('Reconnect to deploy the latest relay') - expect(provider.addWorktree).not.toHaveBeenCalled() - }) - - it('drops the fork remote it just added when the SSH head fetch fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } + // Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is + // exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is + // "first sync" without needing the sync IPC handlers registered in this harness. + it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => { const exec = vi.fn().mockImplementation(async (args: string[]) => { validateGitExecArgs(args) if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } + throw new Error('No such remote') } if (args[0] === 'remote' && args.length === 1) { return { stdout: 'origin\n', stderr: '' } } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } return { stdout: '', stderr: '' } }) - const provider = { - exec, - fetchRemoteTrackingRef: vi - .fn() - .mockImplementation(async (_repoPath: string, remote: string) => { - if (remote === 'pr-contributor-orca') { - throw new Error('network unreachable') - } - }), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) + const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined) + const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined) + const target = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'https://github.com/contributor/orca.git' } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('network unreachable') - - expect(exec).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], '/remote/repo') - expect(provider.addWorktree).not.toHaveBeenCalled() - }) - - // Regression: the rollback used to fire on ownership inherited from a sibling - // worktree, deleting the remote that worktree was still pushing through. - it('keeps a reused fork remote a sibling worktree owns when the SSH head fetch fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - return { - stdout: - args[2] === 'pr-contributor-orca' - ? 'git@github.com:contributor/orca.git\n' - : 'git@github.com:stablyai/orca.git\n', - stderr: '' - } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\npr-contributor-orca\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }) - const provider = { - exec, - fetchRemoteTrackingRef: vi - .fn() - .mockImplementation(async (_repoPath: string, remote: string) => { - if (remote === 'pr-contributor-orca') { - throw new Error('network unreachable') - } - }), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-ssh::/remote/repo-sibling': { - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/other', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - } - }) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('network unreachable') - - expect(exec).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - '/remote/repo' + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + '/remote/repo', + target ) - expect(exec).not.toHaveBeenCalledWith( + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(exec).toHaveBeenCalledWith( ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], '/remote/repo' ) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + '/remote/repo', + 'pr-contributor-orca', + 'contributor/fix', + 'refs/remotes/pr-contributor-orca/contributor/fix' + ) + expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca') }) + + // The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved + // cases used to be exercised here because create minted the remote unconditionally. + // That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at + // create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on + // first sync. Coverage for all three moved with it to + // worktree-remote-push-target-materialization.test.ts, which calls that function directly. }) diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 85a015496ed..8c936764291 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -17,6 +17,7 @@ import { gitExecFileAsyncMock } from './worktrees-test-module-mocks' import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { materializeWorktreePushTargetRemote } from './worktree-remote' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { createdWorktreeList, @@ -243,7 +244,11 @@ describe('registerWorktreeHandlers', () => { expectEveryGitCallRoutedTo('Ubuntu') }) - it('routes fork push target setup through the selected WSL project runtime', async () => { + // Was "routes fork push target setup ... through create": create used to mint the + // fork remote (and route it to the selected WSL distro) unconditionally. It now + // defers to first sync (#17828) -- split in two so each half stays true to a single + // claim: create stays a no-op even for a WSL-routed repo, sync still routes to the distro. + it('does not mint a fork remote at create time for a WSL-routed worktree', async () => { mockSelectedWslProjectRuntime() listWorktreesMock.mockResolvedValue([ { @@ -266,6 +271,43 @@ describe('registerWorktreeHandlers', () => { } }) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).not.toContainEqual(['check-ref-format', '--branch', 'contributor/wsl-fork']) + expect(calls.some((args) => args[0] === 'remote' && args[1] === 'add')).toBe(false) + expect(calls.some((args) => args[0] === 'fetch')).toBe(false) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + }) + ) + }) + + // Companion to the deferral test above: `materializeWorktreePushTargetRemote` is + // exactly what `git:push`/`git:pull`'s local dispatch calls (with the repo's resolved + // wslDistro) before syncing, so this is "first sync" without needing that IPC handler + // registered in this harness. + it('routes fork push target materialization through the selected WSL project runtime', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + const target = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + + const result = await materializeWorktreePushTargetRemote( + '/workspace/repo', + target, + undefined, + undefined, + { wslDistro: 'Ubuntu' } + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) const wslRoutingOptions = { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/wsl-fork'], @@ -303,18 +345,29 @@ describe('registerWorktreeHandlers', () => { ['config', 'remote.pr-contributor-orca.tagOpt', '--no-tags'], wslRoutingOptions ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['config', 'remote.pr-contributor-orca.orca-created', 'true'], + wslRoutingOptions + ) + // Why: the mint's fetch is the one call in this sequence that talks to the network -- + // bounded the same as the deferred short-circuit's fetch (see DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS) + // so a hung credential prompt can't wedge it forever. Every other call here is local-only + // and stays untimed, per `wslRoutingOptions` above. expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', '+refs/heads/contributor/wsl-fork*:refs/remotes/pr-contributor-orca/contributor/wsl-fork*' ], - wslRoutingOptions + { ...wslRoutingOptions, timeout: expect.any(Number) } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/wsl-fork', 'wsl-fork'], - { cwd: '/workspace/wsl-fork', wslDistro: 'Ubuntu' } + // wslDistro threaded through every subprocess this materialize made, not just the adds. + const distros = new Set( + gitExecFileAsyncMock.mock.calls.map( + ([, options]) => (options as { wslDistro?: string } | undefined)?.wslDistro + ) ) + expect(distros).toEqual(new Set(['Ubuntu'])) }) it('routes selected PR branch conflict lookup through the selected WSL project runtime', async () => { diff --git a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts index c5ceb1acafb..ca5c3019844 100644 --- a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts +++ b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts @@ -85,7 +85,12 @@ export async function pruneMetadataMissingFromAuthoritativeLocalScan({ worktreeRetentionPathComparisonKey(repo.path, platform), ...gitWorktrees.map((worktree) => worktreeRetentionPathComparisonKey(worktree.path, platform)) ]) - const probeCandidates = scan.metadata.flatMap((metadata) => { + // Why: only rows a delete could still accept are worth a filesystem probe. This is advisory — + // `pruneSessionlessMissingLocalWorktreeMetadataForRepo` re-checks authoritatively — so it can only + // ever shrink the `stat` fan-out, never widen what gets removed (#17775). + const removableCandidates = + store.selectProbeableLocalWorktreeMetadataCandidates?.(scan) ?? scan.metadata + const probeCandidates = removableCandidates.flatMap((metadata) => { const { worktreeId } = metadata const parsed = splitWorktreeId(worktreeId) const nativeAbsolute = parsed diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 45ccd4183bb..51a0618ba66 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -51,6 +51,7 @@ export async function listDetectedWorktreesForCapturedRepo( let freshScan = true let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined + let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { if (!isCurrent()) { return null @@ -102,6 +103,7 @@ export async function listDetectedWorktreesForCapturedRepo( freshScan = scan.fresh sideEffectToken = scan.sideEffectToken metadataPrune = scan.metadataPrune + hygieneDue = scan.hygieneDue } const aborted = abortedResult() if (aborted) { @@ -123,7 +125,8 @@ export async function listDetectedWorktreesForCapturedRepo( await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, { isCurrent: () => isCurrent() && !providerAbort?.signal.aborted, sideEffectToken, - signal: providerAbort?.signal + signal: providerAbort?.signal, + ...(hygieneDue === undefined ? {} : { hygieneDue }) }) const aborted = abortedResult() if (aborted) { diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index fea200f1a8b..b694bfbf11d 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -16,6 +16,13 @@ import { resetLocalWorktreeScanGenerationsForTests } from '../../../local-worktree-scan-generation' import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import { + __resetLocalWorktreeMetadataPruneGateForTests, + isLocalWorktreeMetadataPruneDue, + markLocalWorktreeMetadataPruneStarted, + recordLocalWorktreeListingForPruneGate, + requireLocalWorktreeMetadataPrune +} from '../../../local-worktree-metadata-prune-gate' import { pruneMetadataMissingFromAuthoritativeLocalScan } from './authoritative-local-worktree-metadata-pruning' // Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. @@ -30,6 +37,7 @@ export type DetectedWorktreeScan = { invalidated: boolean promise: Promise sideEffectToken: DetectedWorktreeSideEffectToken + hygieneDue: boolean metadataPrune?: DetectedWorktreeMetadataPrune } @@ -46,6 +54,8 @@ export type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean sideEffectToken?: DetectedWorktreeSideEffectToken + /** Whether this scan owns the repo's next store-hygiene pass; absent means "not from a local scan". */ + hygieneDue?: boolean metadataPrune?: DetectedWorktreeMetadataPrune } @@ -54,6 +64,7 @@ export const detectedWorktreeScanInFlight = new Map worktree.path) + ) const routingUnchanged = getDetectedWorktreeScanCacheKey(repo.id, getLocalProjectWorktreeGitOptions(store, repo)) === cacheKey @@ -153,7 +179,7 @@ export async function listDetectedGitWorktrees( return { gitWorktrees, fresh, - ...(fresh ? { sideEffectToken: scan.sideEffectToken } : {}), + ...(fresh ? { sideEffectToken: scan.sideEffectToken, hygieneDue: scan.hygieneDue } : {}), ...(fresh && scan.metadataPrune ? { metadataPrune: scan.metadataPrune } : {}) } } finally { @@ -172,9 +198,11 @@ export async function applyFreshDetectedWorktreeScanSideEffects( isCurrent?: () => boolean sideEffectToken?: DetectedWorktreeSideEffectToken signal?: AbortSignal + /** Undefined means the caller owns no cadence (non-local providers); it keeps the eager behavior. */ + hygieneDue?: boolean } = {} ): Promise { - const { isCurrent = () => true, sideEffectToken, signal } = options + const { isCurrent = () => true, sideEffectToken, signal, hygieneDue = true } = options const generationCurrent = () => sideEffectToken === undefined || isLocalWorktreeScanGenerationCurrent(repo.id, sideEffectToken.generation) @@ -211,12 +239,17 @@ export async function applyFreshDetectedWorktreeScanSideEffects( return false } rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees( - store, - repo, - gitWorktrees, - preservedMetadataCandidateIds ? { preservedMetadataCandidateIds } : undefined - ) + // Why: lineage retention is decided against the metadata rows the prune preserved, so running it + // without that pass would drop lineage for rows the pass would have kept. Both halves share the + // hygiene cadence instead. + if (hygieneDue) { + pruneLineageForMissingRepoWorktrees( + store, + repo, + gitWorktrees, + preservedMetadataCandidateIds ? { preservedMetadataCandidateIds } : undefined + ) + } return true } diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts new file mode 100644 index 00000000000..cb8c48833ef --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-hygiene-gate.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' + +const { listRepoWorktreesMock, pruneLineageMock, pruneMetadataMock, registerWorktreeRootsMock } = + vi.hoisted(() => ({ + listRepoWorktreesMock: vi.fn(), + pruneLineageMock: vi.fn(), + pruneMetadataMock: vi.fn(), + registerWorktreeRootsMock: vi.fn() + })) + +vi.mock('../../../repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) +vi.mock('../../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: () => ({}) +})) +vi.mock('../../registered-worktree-roots-cache', () => ({ + getRegisteredWorktreeRootsRevision: () => 1, + registerWorktreeRootsForRepo: registerWorktreeRootsMock +})) +vi.mock('../../../worktree-lineage-pruning', () => ({ + pruneLineageForMissingRepoWorktrees: pruneLineageMock +})) +vi.mock('./authoritative-local-worktree-metadata-pruning', () => ({ + pruneMetadataMissingFromAuthoritativeLocalScan: pruneMetadataMock +})) + +const { + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS, + __resetDetectedWorktreeScanCacheForTests, + applyFreshDetectedWorktreeScanSideEffects, + invalidateDetectedWorktreeScanCache, + listDetectedGitWorktrees +} = await import('./detected-worktree-scan-cache') +const { invalidateLocalWorktreeMetadataPruneInputs } = + await import('../../../local-worktree-metadata-prune-gate') + +const repo = { id: 'repo-1', path: '/repos/one', displayName: 'one' } as Repo + +function worktreeAt(path: string): GitWorktreeInfo { + return { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: path === repo.path } +} + +const captureExpectation = vi.fn(() => ({ repo: { id: repo.id }, metadata: [] })) +const store = { captureNativeLocalWorktreeMetadataScanExpectation: captureExpectation } as never + +/** Each listing must miss the TTL cache, the way a renderer poll past the window does. */ +function advancePastListingTtl(): void { + vi.setSystemTime(Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + 1) +} + +describe('detected worktree scan hygiene gate', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + listRepoWorktreesMock.mockReset().mockResolvedValue([worktreeAt(repo.path)]) + captureExpectation.mockClear() + pruneLineageMock.mockReset() + pruneMetadataMock + .mockReset() + .mockResolvedValue({ scanGenerationCurrent: true, preservedMetadataCandidateIds: new Set() }) + registerWorktreeRootsMock.mockReset() + __resetDetectedWorktreeScanCacheForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs hygiene once and then never again while nothing changes', async () => { + const first = await listDetectedGitWorktrees(store, repo) + expect(first.hygieneDue).toBe(true) + expect(first.metadataPrune).toBeDefined() + + for (let poll = 0; poll < 20; poll += 1) { + advancePastListingTtl() + const scan = await listDetectedGitWorktrees(store, repo) + expect(scan.fresh).toBe(true) + expect(scan.hygieneDue).toBe(false) + expect(scan.metadataPrune).toBeUndefined() + } + expect(captureExpectation).toHaveBeenCalledTimes(1) + }) + + it('still lists on every cache miss while hygiene is parked', async () => { + await listDetectedGitWorktrees(store, repo) + advancePastListingTtl() + await listDetectedGitWorktrees(store, repo) + + expect(listRepoWorktreesMock).toHaveBeenCalledTimes(2) + }) + + it('re-runs hygiene after a worktree lifecycle event', async () => { + await listDetectedGitWorktrees(store, repo) + invalidateDetectedWorktreeScanCache(repo.id) + + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(true) + expect(captureExpectation).toHaveBeenCalledTimes(2) + }) + + it('re-runs hygiene when ownership state may have released a row', async () => { + await listDetectedGitWorktrees(store, repo) + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(false) + + invalidateLocalWorktreeMetadataPruneInputs() + + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(true) + expect(captureExpectation).toHaveBeenCalledTimes(2) + }) + + it('re-runs hygiene when the listing changes with no event to report it', async () => { + await listDetectedGitWorktrees(store, repo) + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(false) + + // An external `git worktree remove` nobody notified us about. + listRepoWorktreesMock.mockResolvedValue([worktreeAt(repo.path), worktreeAt('/repos/one-wt')]) + advancePastListingTtl() + await listDetectedGitWorktrees(store, repo) + + advancePastListingTtl() + expect((await listDetectedGitWorktrees(store, repo)).hygieneDue).toBe(true) + }) + + it('skips both prune halves on a scan that does not own the hygiene pass', async () => { + await applyFreshDetectedWorktreeScanSideEffects( + store, + repo, + [worktreeAt(repo.path)], + undefined, + { + hygieneDue: false + } + ) + + expect(pruneMetadataMock).not.toHaveBeenCalled() + expect(pruneLineageMock).not.toHaveBeenCalled() + // Authorized roots are listing state, not hygiene; they must still be refreshed. + expect(registerWorktreeRootsMock).toHaveBeenCalledTimes(1) + }) + + it('prunes lineage when the caller owns the hygiene pass', async () => { + await applyFreshDetectedWorktreeScanSideEffects( + store, + repo, + [worktreeAt(repo.path)], + undefined, + { + hygieneDue: true + } + ) + + expect(pruneLineageMock).toHaveBeenCalledTimes(1) + }) + + it('keeps the eager behavior for callers that carry no gate', async () => { + await applyFreshDetectedWorktreeScanSideEffects(store, repo, [worktreeAt(repo.path)]) + + expect(pruneLineageMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 4a882ff24b7..d684461a381 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -91,6 +91,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let freshScan = true let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined + let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { @@ -121,6 +122,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo freshScan = scan.fresh sideEffectToken = scan.sideEffectToken metadataPrune = scan.metadataPrune + hygieneDue = scan.hygieneDue } if (freshScan) { await applyFreshDetectedWorktreeScanSideEffects( @@ -129,7 +131,8 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo gitWorktrees, metadataPrune, { - sideEffectToken + sideEffectToken, + ...(hygieneDue === undefined ? {} : { hygieneDue }) } ) } @@ -183,6 +186,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo let freshScan = true let sideEffectToken: DetectedWorktreeSideEffectToken | undefined let metadataPrune: DetectedWorktreeMetadataPrune | undefined + let hygieneDue: boolean | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { @@ -213,10 +217,12 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo freshScan = scan.fresh sideEffectToken = scan.sideEffectToken metadataPrune = scan.metadataPrune + hygieneDue = scan.hygieneDue } if (freshScan) { await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, { - sideEffectToken + sideEffectToken, + ...(hygieneDue === undefined ? {} : { hygieneDue }) }) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) diff --git a/src/main/linux-package-downloaded-status.ts b/src/main/linux-package-downloaded-status.ts new file mode 100644 index 00000000000..df0c9b32e6d --- /dev/null +++ b/src/main/linux-package-downloaded-status.ts @@ -0,0 +1,88 @@ +import type { UpdateStatus } from '../shared/update-status-types' +import { + captureLinuxPackageArtifact, + clearTrackedLinuxPackageArtifact, + getTrackedLinuxPackageArtifact +} from './linux-package-update-recovery' +import { getLinuxPackageType } from './linux-update-package-type' +import type { LinuxPackageArtifact } from './linux-package-update-recovery' + +export const LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE = + 'Orca could not verify the installed Linux package format, so it will not install this update automatically. Download the update from the official release page and install it manually.' +export const LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE = + 'This copy of Orca is managed by your system package manager, so Orca cannot install updates itself. Update Orca through your distribution instead.' +export const LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE = + 'Quit Orca before running the system package install command.' +const PACKAGE_METADATA_UNUSABLE_MESSAGE = + 'The downloaded package metadata could not be verified. Quit Orca before downloading and installing the update from the official release page.' + +export function createLinuxPackageManualInstallStatus( + artifact: Pick +): UpdateStatus { + return { + state: 'error', + message: LINUX_PACKAGE_MANUAL_INSTALL_MESSAGE, + recovery: { + kind: 'linux-package-install', + packageType: artifact.packageType, + reason: 'manual-install-required', + version: artifact.version + } + } +} + +export function getRetainedLinuxPackageManualInstallStatus(): UpdateStatus | null { + const artifact = getTrackedLinuxPackageArtifact() + return artifact ? createLinuxPackageManualInstallStatus(artifact) : null +} + +function getActiveDownloadVersion(status: UpdateStatus): string | null { + if (status.state === 'downloading' || status.state === 'downloaded') { + return status.version + } + if (status.state === 'error' && status.recovery?.kind === 'linux-package-install') { + return status.recovery.version + } + return null +} + +export function shouldIgnoreDownloadedUpdateEvent( + status: UpdateStatus, + infoVersion: string, + pendingVersion: string +): boolean { + const activeDownloadVersion = getActiveDownloadVersion(status) + return ( + activeDownloadVersion === null || + infoVersion !== activeDownloadVersion || + (pendingVersion !== '' && infoVersion !== pendingVersion) + ) +} + +export function resolveLinuxPackageDownloadedStatus(info: { + version: string +}): UpdateStatus | null { + const packageType = getLinuxPackageType() + if (packageType === 'non-root') { + return null + } + if (packageType === 'unusable') { + clearTrackedLinuxPackageArtifact() + return { + state: 'error', + message: LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE, + version: info.version, + retryable: false + } + } + const artifact = captureLinuxPackageArtifact(info) + if (!artifact) { + return { + state: 'error', + message: PACKAGE_METADATA_UNUSABLE_MESSAGE, + version: info.version, + retryable: false + } + } + return createLinuxPackageManualInstallStatus(artifact) +} diff --git a/src/main/linux-package-install-command.test.ts b/src/main/linux-package-install-command.test.ts index 368e6620fd5..c76c062bbce 100644 --- a/src/main/linux-package-install-command.test.ts +++ b/src/main/linux-package-install-command.test.ts @@ -252,3 +252,54 @@ describe('buildLinuxPackageInstallCommand', () => { }) }) }) + +describe('hasTrustedPackageManagerFor', () => { + it('accepts a deb host that has dpkg', async () => { + install('/usr/bin/dpkg') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(true) + }) + + it('accepts a deb host that has only apt', async () => { + install('/usr/bin/apt') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(true) + }) + + // The #17702 case: an Arch rebuild of the .deb inherits the marker but has no deb tooling. + it('rejects a deb marker on a host with only pacman', async () => { + install('/usr/bin/pacman') + install('/usr/bin/sudo') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(false) + }) + + it('rejects an rpm marker on a host with only deb tooling', async () => { + install('/usr/bin/dpkg') + install('/usr/bin/apt') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('rpm')).toBe(false) + }) + + it('accepts each rpm-family manager on its own', async () => { + for (const name of ['zypper', 'dnf', 'yum', 'rpm']) { + vi.resetModules() + executables = new Map() + install(`/usr/bin/${name}`) + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('rpm')).toBe(true) + } + }) + + it('ignores a package manager outside the trusted directories', async () => { + install('/home/user/.local/bin/dpkg') + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(false) + }) + + it('ignores a non-executable file at a trusted path', async () => { + install('/usr/bin/dpkg', { mode: 0o644 }) + const { hasTrustedPackageManagerFor } = await loadCommandModule() + expect(hasTrustedPackageManagerFor('deb')).toBe(false) + }) +}) diff --git a/src/main/linux-package-install-command.ts b/src/main/linux-package-install-command.ts index 60a8ecdca00..728ffcd1d92 100644 --- a/src/main/linux-package-install-command.ts +++ b/src/main/linux-package-install-command.ts @@ -52,6 +52,16 @@ export function resolveTrustedExecutable(name: string): string | null { return null } +/** + * Whether this host has any package manager able to install the marker's format. A repackaged + * install (AUR, Nix, a container rebuild) inherits the `package-type` marker from the .deb/.rpm it + * was built from, so the marker alone never proves the host can act on it. + */ +export function hasTrustedPackageManagerFor(packageType: LinuxRootPackageType): boolean { + const candidates = packageType === 'deb' ? DEB_PACKAGE_MANAGERS : RPM_PACKAGE_MANAGERS + return candidates.some((candidate) => resolveTrustedExecutable(candidate.name) !== null) +} + /** * Builds the interactive command the user pastes into their own terminal. Every token except the * package path is a fixed literal, and the path is POSIX-single-quoted — Orca never runs this. diff --git a/src/main/linux-package-install-diagnostic.test.ts b/src/main/linux-package-install-diagnostic.test.ts index 5b8e1b1c77b..14e82a0763b 100644 --- a/src/main/linux-package-install-diagnostic.test.ts +++ b/src/main/linux-package-install-diagnostic.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as DiagnosticModule from './linux-package-install-diagnostic' const ESC = String.fromCharCode(27) - let diagnostic: typeof DiagnosticModule beforeEach(async () => { @@ -20,64 +19,35 @@ afterEach(() => { }) describe('redactLinuxPackageInstallText', () => { - it('strips ANSI escape sequences', () => { - const text = `${ESC}[31mdpkg: error${ESC}[0m processing` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error processing') + it('strips terminal escapes and control characters', () => { + const text = `${ESC}[?25l${ESC}[31mdpkg:\r\n\terror\u0000${ESC}[0m${ESC}[?25h` + expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error') }) - it('strips ANSI sequences with private and intermediate bytes', () => { - const text = `${ESC}[?25lworking${ESC}[?25h` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('working') + it('strips string escape payloads and remaining two-byte escapes', () => { + const BEL = String.fromCharCode(7) + const text = + `${ESC}]8;;https://tracker.invalid/report${BEL}dpkg${ESC}]8;;${BEL} ` + + `${ESC}P1;2|payload${ESC}\\failed${ESC}c` + expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg failed') }) - it('replaces control characters and collapses whitespace', () => { - const text = `line one\r\n\tline\u0000two spaced\u007f` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('line one line two spaced') - }) - - it('replaces the cached package path with a placeholder', () => { - const packagePath = '/home/user/.cache/orca-updater/Orca-1.2.3.deb' - const text = `dpkg: error processing ${packagePath} (--install)` - expect(diagnostic.redactLinuxPackageInstallText(text, packagePath)).toBe( - 'dpkg: error processing (--install)' - ) - }) - - it('replaces every occurrence of the package path', () => { - const packagePath = '/tmp/orca.deb' - const text = `${packagePath} failed; retry ${packagePath}` - expect(diagnostic.redactLinuxPackageInstallText(text, packagePath)).toBe( - ' failed; retry ' - ) - }) - - it('replaces the home directory with a placeholder', () => { - const home = os.homedir() - const text = `could not read ${home}/.config/orca/settings.json` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe( - 'could not read /.config/orca/settings.json' - ) - }) - - it('prefers the package placeholder for a path inside the home directory', () => { + it('replaces every cached package-path occurrence before the home directory', () => { const home = os.homedir() const packagePath = `${home}/.cache/orca-updater/Orca-1.2.3.deb` - expect(diagnostic.redactLinuxPackageInstallText(`install ${packagePath}`, packagePath)).toBe( - 'install ' + const text = `${packagePath} failed; retry ${packagePath}; config ${home}/.config/orca` + expect(diagnostic.redactLinuxPackageInstallText(text, packagePath)).toBe( + ' failed; retry ; config /.config/orca' ) }) - it('replaces the bare username with a placeholder', () => { - // Why: sudo names the user without any path around it, so the rule never sees it. + it('replaces a bare username without corrupting short names', () => { vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'devuser' } as os.UserInfo) expect( diagnostic.redactLinuxPackageInstallText('devuser is not in the sudoers file', null) ).toBe(' is not in the sudoers file') - }) - it('leaves a username shorter than three characters alone', () => { - // Short names would corrupt unrelated words. - vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'ci' } as os.UserInfo) + vi.mocked(os.userInfo).mockReturnValue({ username: 'ci' } as os.UserInfo) expect(diagnostic.redactLinuxPackageInstallText('ci: incident in circuit', null)).toBe( 'ci: incident in circuit' ) @@ -92,34 +62,23 @@ describe('redactLinuxPackageInstallText', () => { ) }) - it('truncates to 1024 characters', () => { - const result = diagnostic.redactLinuxPackageInstallText('a'.repeat(2000), null) - expect(result).toHaveLength(1024) + it('bounds the result at 1024 characters', () => { + expect(diagnostic.redactLinuxPackageInstallText('a'.repeat(2_000), null)).toHaveLength(1_024) + expect(diagnostic.redactLinuxPackageInstallText('a'.repeat(1_024), null)).toHaveLength(1_024) }) - it('keeps text at exactly the limit', () => { - const result = diagnostic.redactLinuxPackageInstallText('a'.repeat(1024), null) - expect(result).toHaveLength(1024) - }) - - it('returns null for empty and whitespace-only input', () => { + it('returns null when no visible text remains', () => { expect(diagnostic.redactLinuxPackageInstallText('', null)).toBeNull() expect(diagnostic.redactLinuxPackageInstallText(' \n\t ', null)).toBeNull() expect(diagnostic.redactLinuxPackageInstallText(`${ESC}[0m`, null)).toBeNull() - }) - - it('returns null for null and undefined', () => { expect(diagnostic.redactLinuxPackageInstallText(null, null)).toBeNull() expect(diagnostic.redactLinuxPackageInstallText(undefined, null)).toBeNull() }) - it('uses the message of an Error', () => { + it('normalizes errors, objects, and primitives', () => { expect(diagnostic.redactLinuxPackageInstallText(new Error('pkexec failed'), null)).toBe( 'pkexec failed' ) - }) - - it('serializes plain objects and other primitives', () => { expect(diagnostic.redactLinuxPackageInstallText({ code: 127 }, null)).toBe('{"code":127}') expect(diagnostic.redactLinuxPackageInstallText(127, null)).toBe('127') }) @@ -130,208 +89,22 @@ describe('redactLinuxPackageInstallText', () => { expect(diagnostic.redactLinuxPackageInstallText(circular, null)).toBeNull() }) - it('strips an OSC hyperlink along with its URL payload', () => { - const BEL = String.fromCharCode(7) - const text = `${ESC}]8;;https://tracker.invalid/report${BEL}dpkg: error${ESC}]8;;${BEL} processing` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error processing') - }) - - it('strips a string-terminated DCS sequence and a two-byte escape', () => { - const text = `${ESC}P1;2|payload${ESC}\\dpkg${ESC}c: error` - expect(diagnostic.redactLinuxPackageInstallText(text, null)).toBe('dpkg: error') - }) - it('ignores an empty package path', () => { expect(diagnostic.redactLinuxPackageInstallText('plain output', '')).toBe('plain output') }) }) describe('createUpdaterDiagnosticLogger', () => { - it('retains redacted error output while capturing', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture('/tmp/orca.deb') - logger.error(`${ESC}[31mpkexec: /tmp/orca.deb not authorized${ESC}[0m`) - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'pkexec: not authorized', - reason: 'authentication-denied' - }) - }) - - it('ignores non-error levels', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.info('downloading') - logger.warn('retrying') - logger.debug('verbose') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - }) - - it('still forwards every level to the console', () => { + it('forwards every updater level to the matching console method', () => { const logger = diagnostic.createUpdaterDiagnosticLogger() logger.info('a') logger.warn('b') logger.error('c') logger.debug('d') + expect(console.info).toHaveBeenCalledWith('[autoUpdater]', 'a') expect(console.warn).toHaveBeenCalledWith('[autoUpdater]', 'b') expect(console.error).toHaveBeenCalledWith('[autoUpdater]', 'c') expect(console.debug).toHaveBeenCalledWith('[autoUpdater]', 'd') }) - - it('retains nothing outside a capture window', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - logger.error('unrelated failure') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - }) - - it('keeps the last usable error and ignores empty ones', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('first failure') - logger.error('second failure') - logger.error('') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'second failure', - reason: 'package-install-failed' - }) - }) - - it('hands back and clears the diagnostic when capture ends', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('request dismissed') - expect(diagnostic.endLinuxPackageInstallDiagnosticCapture()).toEqual({ - message: 'request dismissed', - reason: 'authentication-denied' - }) - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - logger.error('later noise') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - }) - - it('drops a previous attempt when a new capture begins', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture('/tmp/a.deb') - logger.error('old failure') - diagnostic.beginLinuxPackageInstallDiagnosticCapture('/tmp/b.deb') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toBeNull() - logger.error('new failure at /tmp/b.deb') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'new failure at ', - reason: 'package-install-failed' - }) - }) - - it('classifies the original output, not the redacted text', () => { - // A user named "age" turns "agent" into "nt", which would hide the missing polkit agent. - vi.spyOn(os, 'userInfo').mockReturnValue({ username: 'age' } as os.UserInfo) - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('Error executing command as another user: No authentication agent found for age.') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: - 'Error executing command as another user: No authentication nt found for .', - reason: 'authentication-agent-unavailable' - }) - }) - - it('keeps a specific verdict when a generic line follows it', () => { - // electron-updater logs the polkit output first, then "Command failed, exited with code 126". - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('polkit-agent-helper-1: no authentication agent found') - logger.error('Command failed, exited with code 126') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'polkit-agent-helper-1: no authentication agent found', - reason: 'authentication-agent-unavailable' - }) - }) - - it('lets a later specific line replace an earlier one', () => { - const logger = diagnostic.createUpdaterDiagnosticLogger() - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - logger.error('no authentication agent') - logger.error('request dismissed') - expect(diagnostic.getLinuxPackageInstallDiagnostic()).toEqual({ - message: 'request dismissed', - reason: 'authentication-denied' - }) - }) - - it('returns null from an empty capture window', () => { - diagnostic.beginLinuxPackageInstallDiagnosticCapture(null) - expect(diagnostic.endLinuxPackageInstallDiagnosticCapture()).toBeNull() - }) -}) - -describe('classifyLinuxPackageInstallFailure', () => { - it('reports a missing authentication agent', () => { - for (const text of [ - 'Error executing command as another user: No authentication agent found.', - 'polkit-agent-helper: agent not found', - 'polkit agent was not found' - ]) { - expect(diagnostic.classifyLinuxPackageInstallFailure(text)).toBe( - 'authentication-agent-unavailable' - ) - } - }) - - it('reports a denied authentication', () => { - for (const text of [ - 'Error executing command as another user: Request dismissed', - 'polkit: Authentication failed', - 'Error executing command as another user: Not authorized', - 'Authorization failed for org.freedesktop.policykit.exec', - 'pkexec: 3 incorrect password attempts' - ]) { - expect(diagnostic.classifyLinuxPackageInstallFailure(text)).toBe('authentication-denied') - } - }) - - it('prefers the agent reason when both patterns appear', () => { - expect( - diagnostic.classifyLinuxPackageInstallFailure( - 'No authentication agent found; authentication failed' - ) - ).toBe('authentication-agent-unavailable') - }) - - it('falls back to a generic failure for localized output', () => { - expect( - diagnostic.classifyLinuxPackageInstallFailure( - "Erreur lors de l'exécution : aucun agent d'authentification trouvé" - ) - ).toBe('package-install-failed') - }) - - it('falls back to a generic failure for unrecognized and missing output', () => { - expect(diagnostic.classifyLinuxPackageInstallFailure('dpkg: dependency problems')).toBe( - 'package-install-failed' - ) - expect(diagnostic.classifyLinuxPackageInstallFailure(null)).toBe('package-install-failed') - expect(diagnostic.classifyLinuxPackageInstallFailure('')).toBe('package-install-failed') - }) -}) - -describe('parseLinuxPackageInstallExitCode', () => { - it('parses electron-updater exit-code messages', () => { - expect( - diagnostic.parseLinuxPackageInstallExitCode( - new Error('Command /usr/bin/pkexec exited with code 127') - ) - ).toBe(127) - expect(diagnostic.parseLinuxPackageInstallExitCode('Command failed exited with code 0')).toBe(0) - }) - - it('parses a negative code and matches case-insensitively', () => { - expect(diagnostic.parseLinuxPackageInstallExitCode('Exited With Code -1')).toBe(-1) - }) - - it('returns null when no code is present', () => { - expect(diagnostic.parseLinuxPackageInstallExitCode(new Error('spawn ENOENT'))).toBeNull() - expect(diagnostic.parseLinuxPackageInstallExitCode('exited with code abc')).toBeNull() - expect(diagnostic.parseLinuxPackageInstallExitCode(null)).toBeNull() - expect(diagnostic.parseLinuxPackageInstallExitCode({ code: 127 })).toBeNull() - }) }) diff --git a/src/main/linux-package-install-diagnostic.ts b/src/main/linux-package-install-diagnostic.ts index bdef7c5450e..c65c62679fa 100644 --- a/src/main/linux-package-install-diagnostic.ts +++ b/src/main/linux-package-install-diagnostic.ts @@ -1,16 +1,7 @@ import os from 'node:os' -import type { LinuxPackageInstallFailureReason } from '../shared/update-status-types' - -/** The redacted text shown locally, paired with the reason classified from the ORIGINAL output. */ -export type LinuxPackageInstallDiagnostic = { - message: string - reason: LinuxPackageInstallFailureReason -} const MAX_DIAGNOSTIC_LENGTH = 1_024 -// Built via RegExp so the source carries no raw control bytes. Alternatives in order: CSI; then the -// string sequences (OSC/DCS/PM/APC/SOS), whose payload — an OSC 8 hyperlink URL, say — must be -// dropped with the introducer rather than left behind; then any remaining two-byte escape. +// Alternatives in order: CSI; string sequences whose payload must also be dropped; remaining two-byte escapes. const ANSI_ESCAPE = new RegExp( [ String.raw`\u001b\[[0-9;?]*[ -/]*[@-~]`, @@ -20,28 +11,7 @@ const ANSI_ESCAPE = new RegExp( 'g' ) const CONTROL_CHARACTERS = new RegExp(String.raw`[\u0000-\u001f\u007f]`, 'g') - -// Why: pkexec/polkit print these before any package manager runs; matching them keeps the UI from -// blaming dpkg for an authentication problem. Anything else stays generic on purpose. -const AGENT_UNAVAILABLE_PATTERNS = [ - /no authentication agent/i, - /polkit.{0,20}agent.{0,20}not found/i -] -const AUTHENTICATION_DENIED_PATTERNS = [ - /request dismissed/i, - /authentication failed/i, - /not authorized/i, - /authorization failed/i, - /incorrect password attempt/i -] - -let capturing = false -let retainedDiagnostic: string | null = null -// Why: classification must read the ORIGINAL text. Redaction can rewrite a pattern word — a user -// named "age" turns "No authentication agent found" into "No authentication nt" — which would -// silently downgrade a missing-agent failure to the generic reason. -let retainedReason: LinuxPackageInstallFailureReason | null = null -let redactedPackagePath: string | null = null +const MIN_REDACTED_USERNAME_LENGTH = 3 function stringifyLoggerValue(value: unknown): string { if (typeof value === 'string') { @@ -63,9 +33,6 @@ function stringifyLoggerValue(value: unknown): string { return String(value) } -// Short names would corrupt unrelated words, so they are left alone. -const MIN_REDACTED_USERNAME_LENGTH = 3 - function readUserName(): string | null { try { return os.userInfo().username || null @@ -75,16 +42,10 @@ function readUserName(): string | null { } function replaceAllLiteral(text: string, needle: string, replacement: string): string { - if (needle.length === 0) { - return text - } - return text.split(needle).join(replacement) + return needle.length === 0 ? text : text.split(needle).join(replacement) } -/** - * Turns arbitrary updater/child output into text safe to show locally: no ANSI, no control bytes, - * no home directory, no cached package path, bounded length. - */ +/** Removes terminal escapes and local identity from updater text before showing it in the UI. */ export function redactLinuxPackageInstallText( value: unknown, packagePath: string | null @@ -101,7 +62,7 @@ export function redactLinuxPackageInstallText( if (homeDir) { text = replaceAllLiteral(text, homeDir, '') } - // Why: sudo reports " is not in the sudoers file", which the home-directory rule cannot catch. + // Why: privilege tools can name the user without including their home directory. const userName = readUserName() if (userName && userName.length >= MIN_REDACTED_USERNAME_LENGTH) { text = replaceAllLiteral(text, userName, '') @@ -113,97 +74,16 @@ export function redactLinuxPackageInstallText( return text.length > MAX_DIAGNOSTIC_LENGTH ? text.slice(0, MAX_DIAGNOSTIC_LENGTH) : text } -/** Starts retaining redacted error output for one native root-package install attempt. */ -export function beginLinuxPackageInstallDiagnosticCapture(packagePath: string | null): void { - capturing = true - retainedDiagnostic = null - retainedReason = null - redactedPackagePath = packagePath -} - -/** Stops capture and hands back the retained diagnostic, clearing it for the next attempt. */ -export function endLinuxPackageInstallDiagnosticCapture(): LinuxPackageInstallDiagnostic | null { - const captured = getLinuxPackageInstallDiagnostic() - capturing = false - retainedDiagnostic = null - retainedReason = null - redactedPackagePath = null - return captured -} - -export function getLinuxPackageInstallDiagnostic(): LinuxPackageInstallDiagnostic | null { - return retainedDiagnostic === null - ? null - : { message: retainedDiagnostic, reason: retainedReason ?? 'package-install-failed' } -} - -function recordLinuxPackageInstallDiagnostic(value: unknown): void { - if (!capturing) { - return - } - const raw = stringifyLoggerValue(value) - const redacted = redactLinuxPackageInstallText(raw, redactedPackagePath) - if (!redacted) { - return - } - const reason = classifyLinuxPackageInstallFailure(raw) - // Why: electron-updater logs the polkit output first and a generic "exited with code N" line after, - // so a later generic line must not erase the specific verdict the card branches on. - if ( - reason === 'package-install-failed' && - retainedReason !== null && - retainedReason !== 'package-install-failed' - ) { - return - } - retainedDiagnostic = redacted - retainedReason = reason -} - -/** - * The `autoUpdater.logger`. Every level still reaches the same console method; only error output - * during an in-flight root-package install is retained, redacted, for the recovery card. - */ export function createUpdaterDiagnosticLogger(): { - info: (m: unknown) => void - warn: (m: unknown) => void - error: (m: unknown) => void - debug: (m: unknown) => void + info: (message: unknown) => void + warn: (message: unknown) => void + error: (message: unknown) => void + debug: (message: unknown) => void } { return { - info: (m: unknown) => console.info('[autoUpdater]', m), - warn: (m: unknown) => console.warn('[autoUpdater]', m), - error: (m: unknown) => { - recordLinuxPackageInstallDiagnostic(m) - console.error('[autoUpdater]', m) - }, - debug: (m: unknown) => console.debug('[autoUpdater]', m) + info: (message) => console.info('[autoUpdater]', message), + warn: (message) => console.warn('[autoUpdater]', message), + error: (message) => console.error('[autoUpdater]', message), + debug: (message) => console.debug('[autoUpdater]', message) } } - -export function classifyLinuxPackageInstallFailure( - diagnostic: string | null -): LinuxPackageInstallFailureReason { - if (!diagnostic) { - return 'package-install-failed' - } - if (AGENT_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(diagnostic))) { - return 'authentication-agent-unavailable' - } - if (AUTHENTICATION_DENIED_PATTERNS.some((pattern) => pattern.test(diagnostic))) { - return 'authentication-denied' - } - // Localized or unrecognized output must never be reported as a missing agent. - return 'package-install-failed' -} - -/** Parses the child exit status out of electron-updater's `Command exited with code `. */ -export function parseLinuxPackageInstallExitCode(error: unknown): number | null { - const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - const match = /exited with code (-?\d{1,5})\b/i.exec(message) - if (!match) { - return null - } - const code = Number.parseInt(match[1], 10) - return Number.isFinite(code) ? code : null -} diff --git a/src/main/linux-package-update-recovery.test.ts b/src/main/linux-package-update-recovery.test.ts index ec2738b823b..859e80e74b2 100644 --- a/src/main/linux-package-update-recovery.test.ts +++ b/src/main/linux-package-update-recovery.test.ts @@ -5,18 +5,14 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' import type { LinuxPackageInstallRecovery } from '../shared/update-status-types' +import type { LinuxPackageArtifact } from './linux-package-update-recovery' import type * as RecoveryModule from './linux-package-update-recovery' -const { showItemInFolderMock, getPackageTypeMock, buildCommandMock, hashPasses } = vi.hoisted( - () => ({ - showItemInFolderMock: vi.fn(), - getPackageTypeMock: vi.fn(), - buildCommandMock: vi.fn(), - hashPasses: { count: 0 } - }) -) - -vi.mock('electron', () => ({ shell: { showItemInFolder: showItemInFolderMock } })) +const { getPackageTypeMock, buildCommandMock, hashPasses } = vi.hoisted(() => ({ + getPackageTypeMock: vi.fn(), + buildCommandMock: vi.fn(), + hashPasses: { count: 0 } +})) vi.mock('./linux-update-package-type', () => ({ getLinuxRootPackageType: getPackageTypeMock })) @@ -67,9 +63,9 @@ async function writePackage(name: string, contents = PAYLOAD): Promise { } /** Captures a well-formed downloaded event unless a field is overridden. */ -function capture(overrides: Record = {}): void { +function capture(overrides: Record = {}): LinuxPackageArtifact | null { const downloadedFile = (overrides.downloadedFile ?? path.join(downloadDir, 'orca.deb')) as string - recovery.captureLinuxPackageArtifact({ + return recovery.captureLinuxPackageArtifact({ version: VERSION, files: [{ url: path.basename(downloadedFile), sha512: SHA512 }], ...overrides, @@ -80,7 +76,6 @@ function capture(overrides: Record = {}): void { beforeEach(async () => { vi.resetModules() hashPasses.count = 0 - showItemInFolderMock.mockReset() getPackageTypeMock.mockReset().mockReturnValue('deb') buildCommandMock.mockReset().mockReturnValue({ ok: true, command: 'installed command' }) tempRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'orca-recovery-')) @@ -103,13 +98,14 @@ afterEach(async () => { describe('captureLinuxPackageArtifact', () => { it('retains the downloaded package with the digest from the event metadata', () => { - capture() - expect(recovery.getTrackedLinuxPackageArtifact()).toEqual({ + const artifact = { packageType: 'deb', version: VERSION, path: path.join(downloadDir, 'orca.deb'), sha512: SHA512 - }) + } satisfies LinuxPackageArtifact + expect(capture()).toEqual(artifact) + expect(recovery.getTrackedLinuxPackageArtifact()).toEqual(artifact) }) it('ignores the event on a build that is not a root package', () => { @@ -221,7 +217,7 @@ describe('captureLinuxPackageArtifact', () => { it('keeps a retained artifact when a later event carries a malformed digest', () => { capture() - capture({ files: [{ url: 'orca.deb', sha512: 'not-a-digest' }] }) + expect(capture({ files: [{ url: 'orca.deb', sha512: 'not-a-digest' }] })).toBeNull() expect(recovery.getTrackedLinuxPackageArtifact()?.sha512).toBe(SHA512) }) @@ -596,7 +592,7 @@ describePosix('validation coalescing', () => { capture() const [first, second] = await Promise.all([ recovery.resolveLinuxPackageInstallInstructions(recoveryFor()), - recovery.revealLinuxPackage(recoveryFor()) + recovery.resolveLinuxPackageRevealTarget(recoveryFor()) ]) expect(first.ok).toBe(true) expect(second.ok).toBe(true) @@ -611,31 +607,6 @@ describePosix('validation coalescing', () => { expect(hashPasses.count).toBe(2) }) - // Why: a verdict handed to a root package manager must cover the bytes as of the click, not the - // bytes a Copy click started streaming seconds earlier. - it('never reuses an in-flight pass for a pre-install re-proof', async () => { - await writePackage('orca.deb') - capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) - const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) - - await expect(installPass).resolves.toEqual({ ok: true }) - await expect(copyPass).resolves.toMatchObject({ ok: true }) - expect(hashPasses.count).toBe(2) - }) - - it('lets a later Copy click join the pre-install pass', async () => { - await writePackage('orca.deb') - capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - const installPass = recovery.revalidateLinuxPackageForInstall(artifact!) - const copyPass = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) - - await Promise.all([installPass, copyPass]) - expect(hashPasses.count).toBe(1) - }) - it('does not reuse an in-flight pass for a different artifact', async () => { await writePackage('orca.deb') await writePackage('orca-next.deb') @@ -652,77 +623,43 @@ describePosix('validation coalescing', () => { await Promise.all([first, second]) expect(hashPasses.count).toBe(2) }) -}) -describePosix('revalidateLinuxPackageForInstall', () => { - it('proves the retained package still matches its release digest', async () => { + it('starts a fresh proof when the same package is captured again', async () => { await writePackage('orca.deb') capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ - ok: true - }) - }) - - it('rejects a package swapped after the download was verified', async () => { - await writePackage('orca.deb') + const first = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - await writePackage('orca.deb', 'attacker supplied package') - await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ - ok: false, - reason: 'hash-mismatch' - }) - }) + const second = recovery.resolveLinuxPackageInstallInstructions(recoveryFor()) - it('reports a package deleted from the cache as missing', async () => { - const filePath = await writePackage('orca.deb') - capture() - const artifact = recovery.getTrackedLinuxPackageArtifact() - await fsp.rm(filePath) - await expect(recovery.revalidateLinuxPackageForInstall(artifact!)).resolves.toEqual({ - ok: false, - reason: 'missing' - }) + await Promise.all([first, second]) + expect(hashPasses.count).toBe(2) }) }) -describePosix('revealLinuxPackage', () => { - it('reveals a verified package on the machine that owns it', async () => { +describePosix('resolveLinuxPackageRevealTarget', () => { + it('returns the verified package path', async () => { const filePath = await writePackage('orca.deb') capture() - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ ok: true }) - expect(showItemInFolderMock).toHaveBeenCalledWith(filePath) + await expect(recovery.resolveLinuxPackageRevealTarget(recoveryFor())).resolves.toEqual({ + ok: true, + path: filePath + }) }) - it('does not reveal a package that fails validation', async () => { + it('rejects a package that fails validation', async () => { const filePath = await writePackage('orca.deb') capture() await fsp.writeFile(filePath, 'tampered payload') - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ + await expect(recovery.resolveLinuxPackageRevealTarget(recoveryFor())).resolves.toEqual({ ok: false, reason: 'hash-mismatch' }) - expect(showItemInFolderMock).not.toHaveBeenCalled() }) - it('reports read-failed when the desktop file manager throws', async () => { - await writePackage('orca.deb') - capture() - showItemInFolderMock.mockImplementation(() => { - throw new Error('no file manager available') - }) - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ - ok: false, - reason: 'read-failed' - }) - }) - - it('does not reveal anything without a retained artifact', async () => { - await expect(recovery.revealLinuxPackage(recoveryFor())).resolves.toEqual({ + it('returns missing without a retained artifact', async () => { + await expect(recovery.resolveLinuxPackageRevealTarget(recoveryFor())).resolves.toEqual({ ok: false, reason: 'missing' }) - expect(showItemInFolderMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index e5269a2077b..e0bf530bb65 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -3,7 +3,6 @@ import { createReadStream } from 'node:fs' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { shell } from 'electron' import type { LinuxPackageInstallRecovery, LinuxRootPackageType @@ -36,7 +35,7 @@ export type LinuxPackageInstructionsResult = | { ok: false; reason: LinuxPackageRecoveryUnavailableReason } export type LinuxPackageRevealResult = - | { ok: true } + | { ok: true; path: string } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason } type ValidationResult = @@ -45,7 +44,7 @@ type ValidationResult = let trackedArtifact: LinuxPackageArtifact | null = null // Why: the renderer debounces clicks, but the IPC boundary must not allow parallel hashing of a 160 MB package. -let inFlightValidation: { key: string; promise: Promise } | null = null +const inFlightValidations = new WeakMap>() export function getTrackedLinuxPackageArtifact(): LinuxPackageArtifact | null { return trackedArtifact @@ -117,24 +116,24 @@ function resolveExpectedSha512( } /** - * Retains the verified download so a failed root-package install stays recoverable without paying - * for the 160 MB transfer again. Only the in-memory event metadata is trusted for the digest. + * Retains the downloaded package and its release digest so manual actions do not repeat the 160 MB + * transfer. Only the in-memory event metadata is trusted for the digest. */ -export function captureLinuxPackageArtifact(event: unknown): void { +export function captureLinuxPackageArtifact(event: unknown): LinuxPackageArtifact | null { const packageType = getLinuxRootPackageType() if (!packageType) { - return + return null } const downloadedFile = (event as { downloadedFile?: unknown })?.downloadedFile const version = (event as { version?: unknown })?.version if (typeof downloadedFile !== 'string' || !path.isAbsolute(downloadedFile)) { - return + return null } if (!downloadedFile.toLowerCase().endsWith(`.${packageType}`)) { - return + return null } if (typeof version !== 'string' || version.length === 0) { - return + return null } const sha512 = resolveExpectedSha512( (event as { files?: unknown })?.files, @@ -147,9 +146,11 @@ export function captureLinuxPackageArtifact(event: unknown): void { // Why: an unresolvable digest only means THIS event cannot arm recovery. A previously retained // artifact carries its own digest and is revalidated on every use, so dropping it would force a // needless 160 MB redownload of a file that is still on disk and still verifiable. - return + return null } - trackedArtifact = { packageType, version, path: downloadedFile, sha512 } + const artifact = { packageType, version, path: downloadedFile, sha512 } + trackedArtifact = artifact + return artifact } function isInsideDirectory(root: string, target: string): boolean { @@ -244,26 +245,18 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise { - const key = `${artifact.packageType}:${artifact.version}:${artifact.path}:${artifact.sha512}` - if (!options?.fresh && inFlightValidation?.key === key) { - return inFlightValidation.promise +/** Hashes the exact captured artifact, joining only that capture's in-flight proof. */ +function runValidation(artifact: LinuxPackageArtifact): Promise { + const inFlight = inFlightValidations.get(artifact) + if (inFlight) { + return inFlight } const promise: Promise = validateArtifact(artifact).finally(() => { - // Why: identity, not key — a fresh install pass may already have replaced this entry. - if (inFlightValidation?.promise === promise) { - inFlightValidation = null + if (inFlightValidations.get(artifact) === promise) { + inFlightValidations.delete(artifact) } }) - inFlightValidation = { key, promise } + inFlightValidations.set(artifact, promise) return promise } @@ -305,38 +298,12 @@ export async function resolveLinuxPackageInstallInstructions( } } -/** - * Re-proves the retained package immediately before the privileged installer consumes it. - * - * The cache path is user-writable, so a digest checked when the download finished says nothing - * about the bytes `dpkg -i` will read minutes later. Re-hashing here does not close the race — - * only an immutable handoff would — but it shrinks the window from "since the download" to - * "since this call", and it catches the artifact being swapped or deleted outright. Takes the - * artifact rather than a recovery so both the retry and the plain "Restart to Update" install - * are covered. - */ -export async function revalidateLinuxPackageForInstall( - artifact: LinuxPackageArtifact -): Promise<{ ok: true } | { ok: false; reason: LinuxPackageRecoveryUnavailableReason }> { - const validation = await runValidation(artifact, { fresh: true }) - return validation.ok ? { ok: true } : { ok: false, reason: validation.reason } -} - -export async function revealLinuxPackage( +export async function resolveLinuxPackageRevealTarget( recovery: LinuxPackageInstallRecovery ): Promise { const validation = await validateTrackedArtifact(recovery) if (!validation.ok) { return validation } - // Why: this cache path must not travel through the workspace shell:openPath API, whose execution - // host can be an SSH or WSL machine rather than the one that owns the installed package. - try { - shell.showItemInFolder(validation.artifact.path) - } catch { - // Why: every other failure in this module reports through {ok:false}; a raw throw here would - // reject the IPC with an unredacted message and skip the lifecycle record. - return { ok: false, reason: 'read-failed' } - } - return { ok: true } + return { ok: true, path: validation.artifact.path } } diff --git a/src/main/linux-update-package-type.test.ts b/src/main/linux-update-package-type.test.ts index 2a2858c2c68..7453c727bb8 100644 --- a/src/main/linux-update-package-type.test.ts +++ b/src/main/linux-update-package-type.test.ts @@ -2,16 +2,38 @@ import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LinuxPackageType, LinuxRootPackageType } from './linux-update-package-type' -const { appMock } = vi.hoisted(() => ({ appMock: { isPackaged: true } })) +const { appMock, hasTrustedPackageManagerForMock } = vi.hoisted(() => ({ + appMock: { isPackaged: true }, + hasTrustedPackageManagerForMock: vi.fn(() => true) +})) vi.mock('electron', () => ({ app: appMock })) +vi.mock('./linux-package-install-command', () => ({ + hasTrustedPackageManagerFor: hasTrustedPackageManagerForMock +})) const originalPlatform = process.platform const originalResourcesPath = process.resourcesPath as string | undefined +const originalExecPath = process.execPath +const originalAppImage = process.env.APPIMAGE +const originalAppDir = process.env.APPDIR let resourcesDir: string +type PackageTypeModule = { + getLinuxPackageType: () => LinuxPackageType + getLinuxRootPackageType: () => LinuxRootPackageType | null + isExternallyManagedLinuxInstall: () => boolean + isLegacyAppImageRuntimeIdentity: (identity: { + appImagePath: unknown + appDirPath: unknown + execPath: unknown + resourcesPath: unknown + }) => boolean +} + function setPlatform(platform: string): void { Object.defineProperty(process, 'platform', { configurable: true, value: platform }) } @@ -20,19 +42,25 @@ function setResourcesPath(value: unknown): void { Object.defineProperty(process, 'resourcesPath', { configurable: true, value }) } +function setExecPath(value: string): void { + Object.defineProperty(process, 'execPath', { configurable: true, value }) +} + async function writeMarker(contents: string): Promise { await fsp.writeFile(path.join(resourcesDir, 'package-type'), contents, 'utf8') } -async function loadPackageType(): Promise<() => 'deb' | 'rpm' | null> { - const module = await import('./linux-update-package-type') - return module.getLinuxRootPackageType +async function loadPackageType(): Promise { + return import('./linux-update-package-type') } beforeEach(async () => { vi.resetModules() + hasTrustedPackageManagerForMock.mockReset().mockReturnValue(true) vi.spyOn(console, 'warn').mockImplementation(() => {}) appMock.isPackaged = true + delete process.env.APPIMAGE + delete process.env.APPDIR setPlatform('linux') resourcesDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'orca-package-type-')) setResourcesPath(resourcesDir) @@ -42,140 +70,297 @@ afterEach(async () => { vi.restoreAllMocks() setPlatform(originalPlatform) setResourcesPath(originalResourcesPath) + setExecPath(originalExecPath) + if (originalAppImage === undefined) { + delete process.env.APPIMAGE + } else { + process.env.APPIMAGE = originalAppImage + } + if (originalAppDir === undefined) { + delete process.env.APPDIR + } else { + process.env.APPDIR = originalAppDir + } await fsp.rm(resourcesDir, { recursive: true, force: true }) }) describe('getLinuxRootPackageType', () => { it('reads a deb marker', async () => { await writeMarker('deb') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('deb') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') }) it('reads an rpm marker', async () => { await writeMarker('rpm') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('rpm') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('rpm') + expect(module.getLinuxRootPackageType()).toBe('rpm') }) it('trims surrounding whitespace', async () => { await writeMarker('\n rpm \t\n') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('rpm') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('rpm') + expect(module.getLinuxRootPackageType()).toBe('rpm') }) it('treats the AppImage marker as not a root package', async () => { await writeMarker('AppImage') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('treats an unknown marker value as not a root package', async () => { + it('treats an unknown marker value as unusable', async () => { await writeMarker('snap') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('treats a pacman marker as a recognized but unsupported target', async () => { + it('treats a pacman marker as unusable until recovery supports it', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await writeMarker('pacman') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() - expect(warn).not.toHaveBeenCalled() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) }) it('rejects a marker that only differs by case', async () => { await writeMarker('DEB') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when the marker is missing', async () => { - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + it('uses a legacy AppImage identity when its executable and resources are inside APPDIR', async () => { + process.env.APPIMAGE = '/opt/orca/orca.AppImage' + process.env.APPDIR = '/tmp/.mount_orca' + setExecPath('/tmp/.mount_orca/orca') + setResourcesPath('/tmp/.mount_orca/resources') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when the marker is unreadable', async () => { + it.each([ + ['relative APPIMAGE', 'relative/orca.AppImage', '/tmp/.mount_orca'], + ['relative APPDIR', '/opt/orca/orca.AppImage', 'relative/.mount_orca'] + ])('rejects a legacy identity with %s', async (_label, appImagePath, appDirPath) => { + process.env.APPIMAGE = appImagePath + process.env.APPDIR = appDirPath + setExecPath('/tmp/.mount_orca/orca') + setResourcesPath('/tmp/.mount_orca/resources') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + }) + + it.each([ + ['executable', '/tmp/.mount_orca-shadow/orca', '/tmp/.mount_orca/resources'], + ['resources', '/tmp/.mount_orca/orca', '/tmp/.mount_orca-shadow/resources'] + ])( + 'rejects a prefix-collision outside APPDIR for %s', + async (_label, execPath, resourcesPath) => { + process.env.APPIMAGE = '/opt/orca/orca.AppImage' + process.env.APPDIR = '/tmp/.mount_orca' + setExecPath(execPath) + setResourcesPath(resourcesPath) + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + } + ) + + it('rejects NULs in every legacy AppImage identity path', async () => { + const { isLegacyAppImageRuntimeIdentity } = await loadPackageType() + const identity = { + appImagePath: '/opt/orca/orca.AppImage', + appDirPath: '/tmp/.mount_orca', + execPath: '/tmp/.mount_orca/orca', + resourcesPath: '/tmp/.mount_orca/resources' + } + + for (const field of Object.keys(identity) as (keyof typeof identity)[]) { + expect( + isLegacyAppImageRuntimeIdentity({ ...identity, [field]: `${identity[field]}\0suffix` }) + ).toBe(false) + } + }) + + it('requires every legacy AppImage identity path to be absolute', async () => { + const { isLegacyAppImageRuntimeIdentity } = await loadPackageType() + const identity = { + appImagePath: '/opt/orca/orca.AppImage', + appDirPath: '/tmp/.mount_orca', + execPath: '/tmp/.mount_orca/orca', + resourcesPath: '/tmp/.mount_orca/resources' + } + + for (const field of Object.keys(identity) as (keyof typeof identity)[]) { + expect(isLegacyAppImageRuntimeIdentity({ ...identity, [field]: 'relative/path' })).toBe(false) + } + }) + + it('prefers a package marker over an invalid legacy AppImage identity', async () => { + await writeMarker('deb') + process.env.APPIMAGE = 'relative/orca.AppImage' + process.env.APPDIR = 'relative/.mount_orca' + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') + }) + + it('returns unusable when the marker is missing without AppImage identity', async () => { + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + }) + + it('returns unusable when the marker is unreadable', async () => { // A directory in the marker's place makes readFileSync fail with EISDIR. await fsp.mkdir(path.join(resourcesDir, 'package-type')) - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when resourcesPath is unavailable', async () => { + it('returns unusable when resourcesPath is unavailable', async () => { setResourcesPath(undefined) - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) - it('returns null when resourcesPath is empty', async () => { + it('returns unusable when resourcesPath is empty', async () => { setResourcesPath('') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('ignores a readable marker in an unpackaged dev run', async () => { await writeMarker('deb') appMock.isPackaged = false - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('ignores a readable marker off Linux', async () => { await writeMarker('deb') setPlatform('darwin') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('non-root') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('caches the resolved type for the process lifetime', async () => { await writeMarker('deb') - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBe('deb') + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') await writeMarker('rpm') - expect(getLinuxRootPackageType()).toBe('deb') + expect(module.getLinuxPackageType()).toBe('deb') + expect(module.getLinuxRootPackageType()).toBe('deb') }) - it('caches a resolved null so a later marker is not picked up', async () => { - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() + it('caches an unusable result so a later marker is not picked up', async () => { + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') await writeMarker('deb') - expect(getLinuxRootPackageType()).toBeNull() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() }) it('warns about an unknown marker value', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await writeMarker('snap') - const getLinuxRootPackageType = await loadPackageType() - getLinuxRootPackageType() + const module = await loadPackageType() + module.getLinuxPackageType() expect(warn).toHaveBeenCalledTimes(1) - expect(warn.mock.calls[0][0]).toContain('marker is not deb or rpm') + expect(warn.mock.calls[0][0]).toContain('marker is not AppImage, deb, or rpm') }) it('reads the marker once and warns once per process', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) // A directory in place of the marker is readable-but-unusable, which is the case worth reporting. await fsp.mkdir(path.join(resourcesDir, 'package-type')) - const getLinuxRootPackageType = await loadPackageType() - getLinuxRootPackageType() - expect(getLinuxRootPackageType()).toBeNull() + const module = await loadPackageType() + module.getLinuxPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0][0]).toContain('marker unreadable') }) - // Why: AppImage ships no marker at all, so the normal case must stay silent. - it('stays silent when no marker is present', async () => { + it('warns when a packaged marker is missing', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const getLinuxRootPackageType = await loadPackageType() - expect(getLinuxRootPackageType()).toBeNull() - expect(warn).not.toHaveBeenCalled() + const module = await loadPackageType() + expect(module.getLinuxPackageType()).toBe('unusable') + expect(module.getLinuxRootPackageType()).toBeNull() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('marker missing')) }) it('does not warn in a dev run', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) appMock.isPackaged = false - const getLinuxRootPackageType = await loadPackageType() - getLinuxRootPackageType() + const module = await loadPackageType() + module.getLinuxPackageType() expect(warn).not.toHaveBeenCalled() }) }) + +describe('isExternallyManagedLinuxInstall', () => { + // The #17702 case: an AUR/Nix/container rebuild of the .deb inherits `package-type` verbatim. + it('reports a deb marker with no deb package manager as externally managed', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('deb') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(true) + expect(hasTrustedPackageManagerForMock).toHaveBeenCalledWith('deb') + }) + + it('reports an rpm marker with no rpm package manager as externally managed', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('rpm') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(true) + expect(hasTrustedPackageManagerForMock).toHaveBeenCalledWith('rpm') + }) + + it('leaves a real deb host self-updatable', async () => { + await writeMarker('deb') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(false) + }) + + it('never probes the host for an AppImage install', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('AppImage') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(false) + expect(hasTrustedPackageManagerForMock).not.toHaveBeenCalled() + }) + + it('never probes the host for an unusable marker', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('snap') + const module = await loadPackageType() + expect(module.isExternallyManagedLinuxInstall()).toBe(false) + expect(hasTrustedPackageManagerForMock).not.toHaveBeenCalled() + }) + + it('probes the host at most once per process', async () => { + hasTrustedPackageManagerForMock.mockReturnValue(false) + await writeMarker('deb') + const module = await loadPackageType() + module.isExternallyManagedLinuxInstall() + module.isExternallyManagedLinuxInstall() + module.isExternallyManagedLinuxInstall() + expect(hasTrustedPackageManagerForMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/linux-update-package-type.ts b/src/main/linux-update-package-type.ts index 5acc83a4cf3..f58c6cf16ad 100644 --- a/src/main/linux-update-package-type.ts +++ b/src/main/linux-update-package-type.ts @@ -1,57 +1,136 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { app } from 'electron' +import { hasTrustedPackageManagerFor } from './linux-package-install-command' import type { LinuxRootPackageType } from '../shared/update-status-types' export type { LinuxRootPackageType } -// Why: `undefined` means "not resolved yet"; `null` is a resolved "not a root package". -let cachedPackageType: LinuxRootPackageType | null | undefined +/** The packaged Linux format that controls how updates may be installed. */ +export type LinuxPackageType = LinuxRootPackageType | 'non-root' | 'unusable' + +// Why: `undefined` means "not resolved yet"; every other value is stable for this process. +let cachedPackageType: LinuxPackageType | undefined +let cachedExternallyManaged: boolean | undefined // Bounded by construction: the marker is read at most once per process. function warnMarkerUnusable(detail: string): void { console.warn(`[updater] linux package-type marker unusable: ${detail}`) } -function readPackageTypeMarker(): LinuxRootPackageType | null { +function isAbsolutePathString(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && !value.includes('\0') && path.isAbsolute(value) + ) +} + +function isInsideDirectory(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate) + return ( + relative.length > 0 && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ) +} + +export function isLegacyAppImageRuntimeIdentity(identity: { + appImagePath: unknown + appDirPath: unknown + execPath: unknown + resourcesPath: unknown +}): boolean { + if ( + !isAbsolutePathString(identity.appImagePath) || + !isAbsolutePathString(identity.appDirPath) || + !isAbsolutePathString(identity.execPath) || + !isAbsolutePathString(identity.resourcesPath) + ) { + return false + } + return ( + isInsideDirectory(identity.appDirPath, identity.execPath) && + isInsideDirectory(identity.appDirPath, identity.resourcesPath) + ) +} + +function hasLegacyAppImageRuntimeIdentity(resourcesPath: unknown): boolean { + return isLegacyAppImageRuntimeIdentity({ + appImagePath: process.env.APPIMAGE, + appDirPath: process.env.APPDIR, + execPath: process.execPath, + resourcesPath + }) +} + +function readPackageTypeMarker(): LinuxPackageType { if (process.platform !== 'linux' || !app.isPackaged) { - return null + return 'non-root' } const resourcesPath = process.resourcesPath if (typeof resourcesPath !== 'string' || resourcesPath.length === 0) { warnMarkerUnusable('resourcesPath unavailable') - return null + return 'unusable' } let raw: string try { raw = readFileSync(path.join(resourcesPath, 'package-type'), 'utf8') } catch (error) { - // Why: AppImage legitimately ships no marker, so only an unreadable one is worth reporting. - if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { - warnMarkerUnusable('marker unreadable') + if ( + (error as NodeJS.ErrnoException)?.code === 'ENOENT' && + hasLegacyAppImageRuntimeIdentity(resourcesPath) + ) { + return 'non-root' } - return null + warnMarkerUnusable( + (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'marker missing' : 'marker unreadable' + ) + return 'unusable' } const value = raw.trim() if (value === 'deb' || value === 'rpm') { return value } - // Why: electron-updater also supports pacman, but this recovery path covers deb/rpm only — a - // recognized marker is a deliberate scope cut, not a broken install. - if (value !== 'pacman') { - warnMarkerUnusable('marker is not deb or rpm') + if (value === 'AppImage') { + return 'non-root' } - return null + warnMarkerUnusable('marker is not AppImage, deb, or rpm') + return 'unusable' } /** - * The installed Linux package format, or null when this build does not install through a - * root package. Reads only the packaged marker `electron-updater` itself uses — never distro - * files, executable paths, or available package managers. + * Resolves the installed Linux package format. Packaged builds fail closed when their marker is + * missing or unusable unless the legacy APPIMAGE runtime identity is valid. Unpackaged, non-Linux, + * and identified AppImage runs are non-root. */ -export function getLinuxRootPackageType(): LinuxRootPackageType | null { +export function getLinuxPackageType(): LinuxPackageType { if (cachedPackageType === undefined) { cachedPackageType = readPackageTypeMarker() } return cachedPackageType } + +/** Returns a root package type when this build supports manual package recovery. */ +export function getLinuxRootPackageType(): LinuxRootPackageType | null { + const packageType = getLinuxPackageType() + return packageType === 'deb' || packageType === 'rpm' ? packageType : null +} + +/** + * Whether the marker claims a root package format this host cannot install. Repackagers (AUR, Nix, + * container rebuilds) unpack Orca's .deb and inherit its `package-type` verbatim, so the marker + * describes the artifact Orca was built as, never the system that now owns the install. Without a + * matching package manager no downloaded package can ever be applied here. + * + * A false positive is impossible by construction: this reuses the exact manager lists and resolver + * that `buildLinuxPackageInstallCommand` already loops over, so any host flagged here would have + * failed with `no-package-manager` after the download anyway. The gate only moves that verdict + * earlier — it never refuses a host that could have installed the update. + */ +export function isExternallyManagedLinuxInstall(): boolean { + if (cachedExternallyManaged === undefined) { + const packageType = getLinuxRootPackageType() + cachedExternallyManaged = packageType !== null && !hasTrustedPackageManagerFor(packageType) + } + return cachedExternallyManaged +} diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index 52b1d16f21a..c9e97f72e90 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -188,7 +188,11 @@ describe('local worktree filesystem runtime access', () => { 1, expect.objectContaining({ program: 'wsl.exe', - args: expect.arrayContaining(['-d', 'Ubuntu']) + args: expect.arrayContaining(['-d', 'Ubuntu']), + // Why a concrete directory (#16463): the guest path is inside the + // command, and these run while a worktree is being removed -- which is + // the cwd an omitted one would inherit. + cwd: expect.any(String) }) ) const removeArgs = runProcessMock.mock.calls[2]?.[0].args as string[] diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index f5d8714e196..1078b1f50bc 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -3,6 +3,7 @@ import { lstat, readFile } from 'node:fs/promises' import { buildWslExecArgs, quotePosixShell } from '../shared/wsl-login-shell-command' import { removeHostTree } from './host-tree-removal' import { toLinuxPath } from './wsl' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' export { toHostFilesystemPath, toHostRemovalPath } from './host-tree-removal' @@ -36,6 +37,10 @@ async function runWslCommand(distro: string, command: string): Promise { const result = await runProcess({ program: 'wsl.exe', args: buildWslExecArgs(distro, ['sh', '-c', command]), + // Why explicit (#16463): the guest path is inside `command`, so this only + // decides whether CreateProcessW succeeds -- and these calls run while a + // worktree is being removed, which is the cwd an inherited one would be. + cwd: resolveWslInteropSpawnCwd(), timeoutMs: WSL_FILE_OPERATION_TIMEOUT_MS }) if (result.timedOut) { diff --git a/src/main/local-worktree-metadata-prune-gate.test.ts b/src/main/local-worktree-metadata-prune-gate.test.ts new file mode 100644 index 00000000000..9ae5d3b9f8a --- /dev/null +++ b/src/main/local-worktree-metadata-prune-gate.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + __resetLocalWorktreeMetadataPruneGateForTests, + forgetLocalWorktreeMetadataPruneGate, + invalidateLocalWorktreeMetadataPruneInputs, + isLocalWorktreeMetadataPruneDue, + markLocalWorktreeMetadataPruneStarted, + recordLocalWorktreeListingForPruneGate, + requireLocalWorktreeMetadataPrune +} from './local-worktree-metadata-prune-gate' + +describe('local worktree metadata prune gate', () => { + beforeEach(() => { + __resetLocalWorktreeMetadataPruneGateForTests() + }) + + it('is due for a repo it has never seen', () => { + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + }) + + it('stays undue indefinitely once a pass ran and nothing changed', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + recordLocalWorktreeListingForPruneGate('repo-1', ['/a', '/b']) + recordLocalWorktreeListingForPruneGate('repo-1', ['/b', '/a']) + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) + + it('re-arms one repo on its own worktree lifecycle event', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + markLocalWorktreeMetadataPruneStarted('repo-2') + + requireLocalWorktreeMetadataPrune('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + expect(isLocalWorktreeMetadataPruneDue('repo-2')).toBe(false) + }) + + it('re-arms every repo when ownership state may have released a row', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + markLocalWorktreeMetadataPruneStarted('repo-2') + + invalidateLocalWorktreeMetadataPruneInputs() + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + expect(isLocalWorktreeMetadataPruneDue('repo-2')).toBe(true) + }) + + it('runs each repo once per invalidation, not once per scan', () => { + invalidateLocalWorktreeMetadataPruneInputs() + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + markLocalWorktreeMetadataPruneStarted('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) + + it('does not re-arm on the first listing it observes', () => { + markLocalWorktreeMetadataPruneStarted('repo-1') + + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) + + it('re-arms when a later listing differs from the one the last pass ran against', () => { + recordLocalWorktreeListingForPruneGate('repo-1', ['/a', '/b']) + markLocalWorktreeMetadataPruneStarted('repo-1') + + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + }) + + it('drops gate state for a deregistered repo', () => { + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + markLocalWorktreeMetadataPruneStarted('repo-1') + + forgetLocalWorktreeMetadataPruneGate('repo-1') + + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(true) + // The forgotten fingerprint must not later read as a change against a stale entry. + recordLocalWorktreeListingForPruneGate('repo-1', ['/a']) + markLocalWorktreeMetadataPruneStarted('repo-1') + expect(isLocalWorktreeMetadataPruneDue('repo-1')).toBe(false) + }) +}) diff --git a/src/main/local-worktree-metadata-prune-gate.ts b/src/main/local-worktree-metadata-prune-gate.ts new file mode 100644 index 00000000000..0daa8a41b9f --- /dev/null +++ b/src/main/local-worktree-metadata-prune-gate.ts @@ -0,0 +1,103 @@ +/** + * Decides whether a detected-worktree scan owes the store a metadata-hygiene pass. + * + * The pass captures a prune expectation over the repo's whole metadata table, `stat`s every + * path-missing candidate, then prunes metadata and lineage. That is O(all rows) and destructive, so + * it must not ride a polled read path: on a store whose dangling rows outnumber live worktrees + * ~100:1 it re-derives an answer it already has, forever, pinning the main process in filesystem + * completion callbacks (#17775). Rows pinned by a persisted session are never removable, so the + * repetition cannot even make progress. + * + * Nothing the pass reads changes on its own, so it is gated on evidence rather than a clock: + * - a worktree lifecycle event for the repo (the existing worktree-change invalidator hub), + * - a mutation that can make some row *more* removable (see `invalidate…PruneInputs`), + * - the repo's git listing differing from the one the last pass ran against. + * With none of those, the pass is a provable repeat and is skipped outright. + * + * Why only "more removable" mutations count: the listing path itself writes worktree metadata + * (discovery backfill, host-ownership stamping), so bumping on every metadata write would re-arm + * the gate from the very scan it gates and restore the storm. Additions and updates can only + * preserve more rows, so staleness there is harmless. + * + * The failure direction is deliberate. A signal we miss leaves a dangling row in place until the + * next one arrives — hygiene lags, and nothing is deleted that would not have been deleted anyway. + */ + +/** Bumped by evidence that some row may have become removable; repos re-run the pass once each. */ +let pruneInputsGeneration = 0 +const observedGenerationByRepo = new Map() +const listingFingerprintByRepo = new Map() + +/** True until the repo has run a pass against the current generation — so also on the first scan. */ +export function isLocalWorktreeMetadataPruneDue(repoId: string): boolean { + return observedGenerationByRepo.get(repoId) !== pruneInputsGeneration +} + +/** + * Claim the pending pass. Recorded when the expectation is captured rather than when the prune + * finishes: a scan that is invalidated or fails mid-flight must not re-arm the full capture on the + * very next listing poll, and any real change re-bumps the generation anyway. + */ +export function markLocalWorktreeMetadataPruneStarted(repoId: string): void { + observedGenerationByRepo.set(repoId, pruneInputsGeneration) +} + +/** One repo's worktrees changed (create/remove/rename). */ +export function requireLocalWorktreeMetadataPrune(repoId: string): void { + observedGenerationByRepo.delete(repoId) +} + +/** + * Some metadata row may have become removable — a worktree-meta/identity/lineage row was deleted, + * or a session, lease, automation or selection released its claim on a workspace. Repo-agnostic + * because ownership is global state: a session release can unpin a row in any repo. + */ +export function invalidateLocalWorktreeMetadataPruneInputs(): void { + pruneInputsGeneration += 1 +} + +/** + * Backstop for changes no event reported: if Git now lists a different set of worktrees than the + * last pass ran against, that pass's conclusions no longer describe this repo. Costs one join over + * a list we already hold. + */ +export function recordLocalWorktreeListingForPruneGate( + repoId: string, + worktreePaths: readonly string[] +): void { + const fingerprint = [...worktreePaths].sort().join('\0') + const previous = listingFingerprintByRepo.get(repoId) + if (previous === fingerprint) { + return + } + listingFingerprintByRepo.set(repoId, fingerprint) + // Why not on the first listing: a repo we have never scanned is already due by default, and the + // scan that produced this listing is the pass that covers it. Re-arming here would double it. + if (previous !== undefined) { + requireLocalWorktreeMetadataPrune(repoId) + } +} + +/** A deregistered repo leaves no reason to retain its gate state. */ +export function forgetLocalWorktreeMetadataPruneGate(repoId: string): void { + observedGenerationByRepo.delete(repoId) + listingFingerprintByRepo.delete(repoId) +} + +export function __resetLocalWorktreeMetadataPruneGateForTests(): void { + pruneInputsGeneration = 0 + observedGenerationByRepo.clear() + listingFingerprintByRepo.clear() +} + +/** Repo teardown: a full removal retires this repo's gate, and either shape can unpin rows in + * other repos, so the shared inputs are always re-armed. */ +export function retireLocalWorktreeMetadataPruneStateForRepo( + repoId: string, + hostId: string | null +): void { + if (hostId === null) { + forgetLocalWorktreeMetadataPruneGate(repoId) + } + invalidateLocalWorktreeMetadataPruneInputs() +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts new file mode 100644 index 00000000000..cbaafa5ff32 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -0,0 +1,78 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' +import { journalIdentityFor } from './structured-agent-session-attach' +import type { AttachFlowInput } from './structured-agent-session-attach-flow' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +export async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { + const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + // Pre-spawn proof is single-use: this retry may create a child after the durable clear. + try { + try { + record = await input.store.setReservationProcesslessProof({ + sessionId: record.sessionId, + fence, + spawnToken, + processlessAt: null, + now: input.now() + }) + await input.onAcquiring?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record, input.params), + fence, + // Retries must recover the original reservation, not mint a second child. + spawnToken, + ...(record.options ? { options: record.options } : {}), + ...(input.eventSink ? { events: input.eventSink } : {}) + }) + const options = await readNativeSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } + const proved = await input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now(), + ...(options ? { options } : {}) + }) + return { + record: proved, + acquisitionGeneration: acquired.acquisitionGeneration ?? null + } + } catch (error) { + if (isAgentSessionPreSpawnError(error)) { + throw error + } + return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 5c25c895341..0e62a305fa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,7 +5,6 @@ // the decisions that must not be client-supplied — the spawn token, the claim // key, the owner probe — and passes them in. -import { isDeepStrictEqual } from 'node:util' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -16,7 +15,6 @@ import { admitAttachOrRefuse, attachJournal, classifyStoreFailure, - journalIdentityFor, reserveRequestFor, type AgentSessionAttachAuthority, type AgentSessionAttachParams, @@ -28,14 +26,13 @@ import { adapterSupportsCreateIfDeclared } from './structured-agent-session-prov import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, - AgentSessionPreSpawnError, isAgentSessionPreSpawnError, rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { acquireOwner } from './structured-agent-session-acquisition' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -87,6 +84,7 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null let reservedRecord: AgentSessionRecord | null = null + let unsupportedReservationSettlementAttempted = false let replayed = false try { const reserved = await store.reserveOwner( @@ -102,11 +100,18 @@ export async function performAttach( record = reserved.record replayed = reserved.disposition === 'replayed' // Capability can change while the durable reservation is in flight. Recheck - // a replay at its effect boundary so it cannot bypass the support gate. - if ( - replayed && - !adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent) - ) { + // every reservation at its effect boundary so it cannot bypass the support + // gate, and release a pending reservation that support drift invalidated. + reservedRecord = record + if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if ( + record.lease.claimStatus === 'reserved' && + record.lease.handoffStage === 'new-owner-proving' && + record.lease.reservedSpawnToken + ) { + unsupportedReservationSettlementAttempted = true + await settleUnsupportedReservation(input, record) + } return unsupported() } if ( @@ -123,7 +128,6 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - reservedRecord = record if (!agentSessionLeaseAdmitsWriter(record.lease)) { const acquired = await acquireOwner(input, record) record = acquired.record @@ -131,7 +135,7 @@ export async function performAttach( } } catch (error) { const spawnToken = reservedRecord?.lease.reservedSpawnToken - if (reservedRecord && spawnToken) { + if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // A pre-spawn failure is its own processless proof; the settlement records the // evidence and the failed operation in one durable transaction. const exitProof = isAgentSessionPreSpawnError(error) @@ -222,6 +226,34 @@ export async function performAttach( } } +async function settleUnsupportedReservation( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + return + } + try { + await input.store.settleFailedAcquisition({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + message: 'Structured session support changed before the provider could start.' + }, + exitProof: 'processless', + now: input.now() + }) + } catch (error) { + throw new AggregateError([error], 'agent session unsupported reservation settlement failed') + } +} + async function settlePostAcquisitionAttachFailure( input: AttachFlowInput, record: AgentSessionRecord, @@ -260,71 +292,3 @@ async function settlePostAcquisitionAttachFailure( } throw cleanupError } - -/** A reservation with no process behind it is only a promise to spawn; the - * adapter makes it real and the store then grants the writer. */ -async function acquireOwner( - input: AttachFlowInput, - record: AgentSessionRecord -): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { - const fence = record.lease.runtimeFence - const spawnToken = record.lease.reservedSpawnToken - if (!spawnToken) { - throw new Error('agent_session_ownership_unknown') - } - // Pre-spawn proof is single-use: this retry may create a child after the durable clear. - try { - try { - record = await input.store.setReservationProcesslessProof({ - sessionId: record.sessionId, - fence, - spawnToken, - processlessAt: null, - now: input.now() - }) - await input.onAcquiring?.() - } catch (error) { - throw new AgentSessionPreSpawnError(error) - } - const acquired = await input.adapter.acquire({ - identity: journalIdentityFor(record, input.params), - fence, - // Retries must recover the original reservation, not mint a second child. - spawnToken, - ...(record.options ? { options: record.options } : {}), - ...(input.eventSink ? { events: input.eventSink } : {}) - }) - const options = await readNativeSessionOptions({ - adapter: input.adapter, - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - }) - if (record.lease.ownerProcess === null) { - await input.store.commitProcessIdentity({ - sessionId: record.sessionId, - fence, - process: acquired.process, - now: input.now() - }) - } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { - throw new Error('agent_session_ownership_unknown') - } - const proved = await input.store.proveOwner({ - sessionId: record.sessionId, - fence, - link: acquired.link, - now: input.now(), - ...(options ? { options } : {}) - }) - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null - } - } catch (error) { - if (isAgentSessionPreSpawnError(error)) { - throw error - } - return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 607eb101cdf..28286e004d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -154,6 +154,52 @@ describe('processless structured session reservation', () => { expect(adapter.acquire).toHaveBeenCalledOnce() }) + it('releases a new reservation when support drifts before acquisition', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const supportsCreate = vi + .fn>() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + const acquire = vi.fn() + const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + + await expect( + performAttach({ + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-drift', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + ).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + reservedSpawnToken: null, + processlessAt: null, + runtimeFence: 2, + deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } + }) + expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) + }) + it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const storeDir = join(root, 'store') diff --git a/src/main/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts index 44f469f0faa..dfa2f7d6a7e 100644 --- a/src/main/orcad/native-host-abi.test.ts +++ b/src/main/orcad/native-host-abi.test.ts @@ -6,6 +6,8 @@ import { GLIBC_FLOOR, isBelowGlibcFloor, nativeSlotName, + parseIncompatibleArchitecture, + parseMissingSharedLibrary, parseNodeAbiMismatch, parseUnmetGlibcVersion } from './native-host-abi' @@ -97,6 +99,37 @@ describe('loader error parsing', () => { ) ).toEqual({ built: '115', host: '127' }) }) + + it('names both architectures on mach-o, and admits ELF names none', () => { + expect( + parseIncompatibleArchitecture( + "dlopen(/opt/pty.node, 0x0001): tried: '/opt/pty.node' (mach-o file, but is an incompatible architecture (have 'arm64', need 'x86_64'))" + ) + ).toEqual({ built: 'arm64', host: 'x86_64' }) + // The ELF loader refuses without saying what it found, so the verdict stands but the + // numbers do not exist to report. + expect(parseIncompatibleArchitecture('invalid ELF header')).toEqual({ + built: null, + host: null + }) + expect(parseIncompatibleArchitecture('wrong ELF class: ELFCLASS32')).not.toBeNull() + // A wrong-libc binary is not a wrong-arch binary; conflating them sends the operator + // to rebuild for an architecture that was never wrong. + expect(parseIncompatibleArchitecture("version `GLIBC_2.34' not found")).toBeNull() + }) + + it('names the shared object the loader could not open, on either loader', () => { + expect( + parseMissingSharedLibrary( + 'libstdc++.so.6: cannot open shared object file: No such file or directory' + ) + ).toBe('libstdc++.so.6') + expect(parseMissingSharedLibrary('Library not loaded: /usr/local/lib/libfoo.dylib')).toBe( + '/usr/local/lib/libfoo.dylib' + ) + // A symbol version that is absent is a rebuild, not an install: must not match here. + expect(parseMissingSharedLibrary("/lib/libc.so.6: version `GLIBC_2.34' not found")).toBeNull() + }) }) describe('detectNativeHostAbi', () => { diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts index 2890bb07a15..2335ad3274b 100644 --- a/src/main/orcad/native-host-abi.ts +++ b/src/main/orcad/native-host-abi.ts @@ -121,3 +121,40 @@ export function parseNodeAbiMismatch(loaderError: string): { built: string; host const match = loaderError.match(/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/) return match ? { built: match[1], host: match[2] } : null } + +/** + * The architecture the loader refused, e.g. `incompatible architecture (have 'arm64', + * need 'x86_64')` -> { built: 'arm64', host: 'x86_64' }. ELF hosts name no architecture + * (`invalid ELF header`, `wrong ELF class: ELFCLASS32`), so both sides read null there — + * the verdict still holds, only the numbers are missing. + */ +export function parseIncompatibleArchitecture( + loaderError: string +): { built: string | null; host: string | null } | null { + const machO = loaderError.match( + /incompatible architecture \(have '?([\w.]+)'?,?\s*need '?([\w.]+)'?/ + ) + if (machO) { + return { built: machO[1], host: machO[2] } + } + if (/invalid ELF header|wrong ELF class|Exec format error|ELFCLASS(?:32|64)/.test(loaderError)) { + return { built: null, host: null } + } + return null +} + +/** + * The shared object the loader could not find, e.g. + * `libstdc++.so.6: cannot open shared object file` -> 'libstdc++.so.6'. + * + * Kept apart from the glibc floor deliberately: a missing library can be installed, + * whereas a symbol version that does not exist can only be fixed by rebuilding. + */ +export function parseMissingSharedLibrary(loaderError: string): string | null { + const elf = loaderError.match(/([\w.+-]+\.so[\w.]*): cannot open shared object file/) + if (elf) { + return elf[1] + } + const machO = loaderError.match(/Library not loaded:\s*(\S+)/) + return machO ? machO[1] : null +} diff --git a/src/main/orcad/node-pty-loader-diagnosis.ts b/src/main/orcad/node-pty-loader-diagnosis.ts new file mode 100644 index 00000000000..8024a8e0ecb --- /dev/null +++ b/src/main/orcad/node-pty-loader-diagnosis.ts @@ -0,0 +1,85 @@ +/** + * Read a dynamic-loader message and name the one thing that has to change. + * + * Pure on purpose: every shape that matters here belongs to a host we are not — Alpine, + * Ubuntu 20.04, an arm64 box handed an x64 binary — so the classification has to be + * testable from a machine that cannot reproduce any of them. + * + * Shared by the two places a node-pty load can fail: `orcad`'s boot precondition + * (out of process, before anything requires node-pty) and the SSH relay's spawn path. + */ +import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' +import { + parseIncompatibleArchitecture, + parseMissingSharedLibrary, + parseNodeAbiMismatch, + parseUnmetGlibcVersion +} from './native-host-abi' + +export type NodePtyLoadCause = { + reason: RuntimeTerminalUnavailableReason + /** Short human phrase naming the actual values found, not a remedy. */ + detail: string +} + +/** + * node-pty's own loader walks several directories and rethrows only the LAST failure, + * wrapped in this sentence. The tail is therefore the `prebuilds/-` + * miss — `Cannot find module` — even when the real failure was the dynamic loader + * refusing `build/Release/pty.node`. Anything acting on that tail sends the operator to + * install a module that is already installed. + */ +export function isFlattenedNodePtyLoaderMessage(message: string): boolean { + return /Failed to load native module: (?:conpty|pty)\.node(?:,|:|$)/.test(message) +} + +/** The real cause a flattened message still carries, when the last attempt was the telling one. */ +export function classifyNodePtyLoaderMessage(message: string): NodePtyLoadCause { + const abiMismatch = parseNodeAbiMismatch(message) + if (abiMismatch) { + return { + reason: 'abi_mismatch', + detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` + } + } + const unmetGlibc = parseUnmetGlibcVersion(message) + if (unmetGlibc) { + return { reason: 'libc_floor', detail: `the binary requires GLIBC_${unmetGlibc}` } + } + const unmetCxx = message.match(/((?:GLIBCXX_|CXXABI_)[0-9.]+)'? not found/) + if (unmetCxx) { + return { reason: 'libc_floor', detail: `the binary requires ${unmetCxx[1]}` } + } + const arch = parseIncompatibleArchitecture(message) + if (arch) { + return { + reason: 'arch_mismatch', + detail: + arch.built && arch.host + ? `built for ${arch.built}, this host needs ${arch.host}` + : `the loader rejected the binary's format (${firstErrorLine(message)})` + } + } + const missingLibrary = parseMissingSharedLibrary(message) + if (missingLibrary) { + return { + reason: 'shared_library_missing', + detail: `${missingLibrary} is not installed on this host` + } + } + if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { + return { reason: 'dependency_missing', detail: firstErrorLine(message) } + } + return { reason: 'load_failed', detail: firstErrorLine(message) } +} + +/** + * Why not simply the first non-empty line: when a child dies without catching, node + * prints the offending source line and a caret before the error, so line one is the + * script rather than the diagnosis. Prefer the first line that reads as an error. + */ +export function firstErrorLine(text: string): string { + const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) + const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) + return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) +} diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts index 00880eee1e5..85aafddc7a1 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.test.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -17,6 +17,14 @@ const LINUX_GLIBC: NativeHostAbi = { nodeAbi: '127' } +const DARWIN_ARM64: NativeHostAbi = { + platform: 'darwin', + arch: 'arm64', + libc: 'none', + glibcVersion: null, + nodeAbi: '127' +} + const dirs: string[] = [] const temp = (): string => { const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) @@ -48,18 +56,32 @@ describe('resolveOrcadPrebuildsDir', () => { }) describe('installPrebuiltSlot', () => { - it('installs the slot binary and spawn-helper into build/Release', () => { + it('installs the slot binary and spawn-helper into build/Release on macOS', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'darwin-arm64') + + const outcome = installPrebuiltSlot({ abi: DARWIN_ARM64, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toEqual({ installed: true, slot: 'darwin-arm64', spawnHelper: true }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) + // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. + const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) + expect(helper.mode & 0o111).not.toBe(0) + }) + + it('installs a Linux slot without claiming a spawn-helper it never execs', () => { + // node-pty builds spawn-helper only under binding.gyp's OS=="mac"; reporting one off + // macOS is what made every Linux orcad boot degraded on spawn_helper_missing (#17844). const prebuilds = temp() const nodePtyDir = temp() stageSlot(prebuilds, 'linux-x64-glibc') const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) - expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: true }) + expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: false }) expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) - // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. - const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) - expect(helper.mode & 0o111).not.toBe(0) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper'))).toBe(false) }) it('will not load a glibc slot on a musl host', () => { diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts index d0623689902..7dcdebba3da 100644 --- a/src/main/orcad/node-pty-prebuilt-slot.ts +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -16,6 +16,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' +import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import { nativeSlotName, type NativeHostAbi } from './native-host-abi' export type PrebuiltSlotManifest = { @@ -103,11 +104,11 @@ export function installPrebuiltSlot(options: { mkdirSync(releaseDir, { recursive: true }) copyFileSync(source, join(releaseDir, 'pty.node')) - // Why this matters as much as pty.node: on Unix node-pty posix_spawns + // Why this matters as much as pty.node: on macOS node-pty posix_spawns // build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment // a user opens a terminal, long after the "install succeeded" line. let spawnHelper = false - if (options.abi.platform !== 'win32') { + if (usesNodePtySpawnHelper(options.abi.platform)) { const helperSource = join(prebuildsDir, slot, 'spawn-helper') if (existsSync(helperSource)) { const helperDest = join(releaseDir, 'spawn-helper') diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts index fadb144d1ff..76d842a9456 100644 --- a/src/main/orcad/node-pty-precondition.test.ts +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -31,10 +31,10 @@ const realNodePtyLoads = ((): boolean => { if (!existsSync(REAL_PTY_NODE)) { return false } - // Why spawn-helper too: a slot without it is legitimately 'degraded', so a test that - // expects 'ok' has an unsatisfiable premise on a host that lacks it. CI has the - // binding but not the helper, which is what made the previous gate insufficient. - if (process.platform !== 'win32' && !existsSync(REAL_SPAWN_HELPER)) { + // Why spawn-helper too: on macOS a slot without it is legitimately 'degraded', so a + // test that expects 'ok' has an unsatisfiable premise on a host that lacks it. Only + // macOS builds the helper, so gating other platforms on it never lets them run. + if (process.platform === 'darwin' && !existsSync(REAL_SPAWN_HELPER)) { return false } const probe = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(REAL_PTY_NODE)})`], { @@ -240,8 +240,8 @@ describe('checkNodePtyPrecondition', () => { // Why gated on the real binding: this asserts a LOAD outcome, so it needs a pty.node // built for the Node ABI. CI's shard never runs ensure-native-runtime, so the copy - // ENOENT'd there. - it.runIf(process.platform !== 'win32' && realNodePtyLoads)( + // ENOENT'd there. macOS only — it is the only platform that execs spawn-helper. + it.runIf(process.platform === 'darwin' && realNodePtyLoads)( 'degrades rather than blocks when only spawn-helper is missing', () => { // node-pty posix_spawns spawn-helper, so this host loads fine and then fails ENOENT @@ -258,6 +258,31 @@ describe('checkNodePtyPrecondition', () => { } ) + // Same staging as above, read through a Linux ABI: node-pty builds spawn-helper only + // under binding.gyp's OS=="mac", so demanding one here called every healthy Linux + // orcad degraded while its terminals worked (#17844). Gated on a loadable binding for + // the same reason as the macOS case; the ABI is what makes it a Linux verdict. + it.runIf(process.platform !== 'win32' && realNodePtyLoads)( + 'does not call a Linux host degraded over a spawn-helper it never execs', + () => { + const dir = stageNodePty() + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'), + join(dir, 'build', 'Release', 'pty.node') + ) + expect(existsSync(join(dir, 'build', 'Release', 'spawn-helper'))).toBe(false) + + const verdict = checkNodePtyPrecondition({ + nodePtyDir: dir, + prebuildsDir: null, + abi: { platform: 'linux', arch: 'x64', libc: 'glibc', glibcVersion: '2.31', nodeAbi: '127' } + }) + + expect(verdict).toMatchObject({ status: 'ok', slot: 'linux-x64-glibc' }) + expect(verdict.reason).toBeUndefined() + } + ) + // Why split: the "ok" half needs a REAL loadable pty.node, which only exists after // `ensure-native-runtime --runtime=node`. CI's shard runs vitest directly, so copying // from node_modules ENOENT'd there. Slot *placement* is the logic worth checking on diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts index 2857449c736..ff41a14cf81 100644 --- a/src/main/orcad/node-pty-precondition.ts +++ b/src/main/orcad/node-pty-precondition.ts @@ -19,19 +19,15 @@ import { existsSync, accessSync, constants } from 'node:fs' import { dirname, join } from 'node:path' import process from 'node:process' import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' import { buildToolchainProbeCommand, parseBuildToolchainProbe, toolchainInstallHintLines } from '../ssh/build-toolchain-diagnosis' -import { - detectNativeHostAbi, - nativeSlotName, - parseNodeAbiMismatch, - parseUnmetGlibcVersion, - type NativeHostAbi -} from './native-host-abi' +import { detectNativeHostAbi, nativeSlotName, type NativeHostAbi } from './native-host-abi' +import { classifyNodePtyLoaderMessage, firstErrorLine } from './node-pty-loader-diagnosis' import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot' // Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr @@ -72,21 +68,35 @@ export type NodePtyProbeFailure = { } /** - * Read the child's exit into a cause. Pure, so every failure shape is testable from a - * host that cannot reproduce it — the whole point, since the shapes that matter belong - * to Alpine and Ubuntu 20.04. + * What the child actually reported, before any judgement is made about it. + * + * Split from the classification so callers that need the loader's own words — the relay, + * which quotes them back when nothing recognizes the shape — do not have to re-derive + * them from a formatted verdict. */ -export function classifyNodePtyProbeResult( +export type NodePtyProbeOutcome = + | { kind: 'loaded'; loadedDir: string | null } + | { kind: 'noBinary' } + | { kind: 'loaderError'; message: string } + | { kind: 'signalled'; signal: NodeJS.Signals } + /** The probe never answered. Not evidence about node-pty either way. */ + | { kind: 'unanswered'; detail: string } + /** It answered, but with nothing that names a cause. */ + | { kind: 'unexplained'; detail: string } + +export function readNodePtyProbeOutcome( result: Pick -): NodePtyProbeFailure | null { +): NodePtyProbeOutcome { const stdout = result.stdout if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) { - return null + return { + kind: 'loaded', + loadedDir: stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() || null + } } if (result.timedOut) { return { - status: 'unverifiable', - reason: 'unknown', + kind: 'unanswered', detail: 'the node-pty load probe did not finish in time, so nothing was established' } } @@ -94,27 +104,51 @@ export function classifyNodePtyProbeResult( // the loader never reaches the catch, and often prints nothing at all. That silence is // exactly the uncatchable case this probe is a separate process for. if (result.signal) { - return { - status: 'blocked', - reason: 'load_crashed', - detail: `the load probe was killed by ${result.signal}` - } + return { kind: 'signalled', signal: result.signal } } if (stdout.includes(NO_BINARY_TOKEN)) { - return { - status: 'blocked', - reason: 'dependency_missing', - detail: 'node-pty is installed but has no compiled binary for this platform' - } + return { kind: 'noBinary' } } const reported = readReportedLoadError(stdout) if (reported !== null) { - return classifyLoaderMessage(reported) + return { kind: 'loaderError', message: reported } } return { - status: 'blocked', - reason: 'load_failed', - detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}` + kind: 'unexplained', + detail: firstErrorLine(result.stderr) || `the load probe exited with code ${result.code}` + } +} + +/** + * Read the child's exit into a cause. Pure, so every failure shape is testable from a + * host that cannot reproduce it — the whole point, since the shapes that matter belong + * to Alpine and Ubuntu 20.04. + */ +export function classifyNodePtyProbeResult( + result: Pick +): NodePtyProbeFailure | null { + const outcome = readNodePtyProbeOutcome(result) + switch (outcome.kind) { + case 'loaded': + return null + case 'unanswered': + return { status: 'unverifiable', reason: 'unknown', detail: outcome.detail } + case 'signalled': + return { + status: 'blocked', + reason: 'load_crashed', + detail: `the load probe was killed by ${outcome.signal}` + } + case 'noBinary': + return { + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + } + case 'loaderError': + return classifyLoaderMessage(outcome.message) + case 'unexplained': + return { status: 'blocked', reason: 'load_failed', detail: outcome.detail } } } @@ -133,40 +167,7 @@ function readReportedLoadError(stdout: string): string | null { /** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */ export function classifyLoaderMessage(message: string): NodePtyProbeFailure { - const abiMismatch = parseNodeAbiMismatch(message) - if (abiMismatch) { - return { - status: 'blocked', - reason: 'abi_mismatch', - detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` - } - } - const unmetGlibc = parseUnmetGlibcVersion(message) - if (unmetGlibc) { - return { - status: 'blocked', - reason: 'libc_floor', - detail: `the binary requires GLIBC_${unmetGlibc}` - } - } - if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) { - return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) } - } - if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { - return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) } - } - return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) } -} - -/** - * Why not simply the first non-empty line: when the child dies without catching, node - * prints the offending source line and a caret before the error, so line one is the - * script rather than the diagnosis. Prefer the first line that reads as an error. - */ -function firstLine(text: string): string { - const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) - const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) - return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) + return { status: 'blocked', ...classifyNodePtyLoaderMessage(message) } } /** @@ -302,11 +303,12 @@ export function checkNodePtyPrecondition( } } - // Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns + // Loaded. The remaining way terminals fail is spawn-time: on macOS node-pty posix_spawns // build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT // on a host that otherwise looks healthy. That is a degradation, not a boot blocker. - const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() - if (abi.platform !== 'win32') { + const outcome = readNodePtyProbeOutcome(result) + const loadedDir = outcome.kind === 'loaded' ? outcome.loadedDir : null + if (usesNodePtySpawnHelper(abi.platform)) { const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper') if (!isExecutableFile(helper)) { return { diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index f8edf0ee466..699189d1390 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -59,6 +59,7 @@ export function readLegacyTerminalScrollbackSettings( type RetiredGlobalSettings = { terminalScrollbackBytes?: unknown enableGitHubAttribution?: unknown + showAgentsSidebar?: unknown } export function stripRetiredGlobalSettings( @@ -67,10 +68,12 @@ export function stripRetiredGlobalSettings( const { terminalScrollbackBytes: _legacyScrollbackBytes, enableGitHubAttribution: _legacyGitHubAttribution, + showAgentsSidebar: _legacyShowAgentsSidebar, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings void _legacyScrollbackBytes void _legacyGitHubAttribution + void _legacyShowAgentsSidebar return rest } diff --git a/src/main/persistence/applying-settings/ui-state-read.ts b/src/main/persistence/applying-settings/ui-state-read.ts index 7a249934260..a640f971f89 100644 --- a/src/main/persistence/applying-settings/ui-state-read.ts +++ b/src/main/persistence/applying-settings/ui-state-read.ts @@ -62,6 +62,7 @@ export function getPersistedUI( markdownTocPanelWidth: clampMarkdownTocPanelWidth(state.ui?.markdownTocPanelWidth), combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth(state.ui?.combinedDiffFileTreeWidth), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(state.ui?.visibleWorkspaceHostIds), + agentsVisibleHostIds: normalizeVisibleExecutionHostIds(state.ui?.agentsVisibleHostIds), workspaceHostOrder: normalizeExecutionHostOrder(state.ui?.workspaceHostOrder), manualRepoOrder: normalizeManualRepoOrder(state.ui?.manualRepoOrder), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(state.ui?.browserDefaultZoomLevel), diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index b93ddfb1296..db06a2738fd 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -152,6 +152,10 @@ export function updatePersistedUI( sanitizedUpdates.visibleWorkspaceHostIds !== undefined ? normalizeVisibleExecutionHostIds(sanitizedUpdates.visibleWorkspaceHostIds) : normalizeVisibleExecutionHostIds(operations.state.ui?.visibleWorkspaceHostIds), + agentsVisibleHostIds: + sanitizedUpdates.agentsVisibleHostIds !== undefined + ? normalizeVisibleExecutionHostIds(sanitizedUpdates.agentsVisibleHostIds) + : normalizeVisibleExecutionHostIds(operations.state.ui?.agentsVisibleHostIds), workspaceHostOrder: sanitizedUpdates.workspaceHostOrder !== undefined ? normalizeExecutionHostOrder(sanitizedUpdates.workspaceHostOrder) diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index 2759797c442..4dbad3f5007 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -3,6 +3,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' export type SshPtyLeaseOperations = { state: PersistedState @@ -272,6 +273,8 @@ export function removeSshRemotePtyLease( (lease) => lease.targetId !== targetId || lease.ptyId !== relayPtyId ) if (operations.state.sshRemotePtyLeases.length !== before) { + // Why: the lease may have been the last claim on a dangling metadata row (#17775). + invalidateLocalWorktreeMetadataPruneInputs() operations.flush() } } @@ -287,6 +290,8 @@ export function removeSshRemotePtyLeases( (lease) => lease.targetId !== targetId ) if (operations.state.sshRemotePtyLeases.length !== before) { + // Why: the leases may have been the last claim on dangling metadata rows (#17775). + invalidateLocalWorktreeMetadataPruneInputs() operations.flush() } } diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index 3e56f2aaf8f..9f33f508fd9 100644 --- a/src/main/persistence/loading-store/automation-persistence.ts +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -29,6 +29,7 @@ import { import { createAutomationRun as createAutomationRunOperation, listAutomationRuns as listAutomationRunsOperation, + listAutomationRunsPage as listAutomationRunsOperationPage, recordRepeatedAutomationSkip as recordRepeatedAutomationSkipOperation, snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, updateAutomationRun as updateAutomationRunOperation, @@ -125,6 +126,15 @@ export class AutomationPersistence { ) } + listAutomationRunsPage(automationId?: string, limit?: number, cursor?: string) { + return listAutomationRunsOperationPage( + this[automationPersistenceContext].runtime.state, + automationId, + limit, + cursor + ) + } + createAutomation( input: AutomationCreateInput, options?: { destination?: AutomationDestination } diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 7d4867ce476..4b0a301fe26 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -13,6 +13,7 @@ import { import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import type { SessionHostPartitionOperations } from './session-host-partitions' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { scheduleSave } from './write-scheduling' import { hasPersistedWorkspaceSession, @@ -32,6 +33,7 @@ import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' import { captureNativeLocalWorktreeMetadataScanExpectation as captureNativeLocalWorktreeMetadataScanExpectationOperation, pruneSessionlessMissingLocalWorktreeMetadataForRepo as pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation, + selectProbeableLocalWorktreeMetadataCandidates as selectProbeableLocalWorktreeMetadataCandidatesOperation, type LocalWorktreeMetadataPruneExpectation, type NativeLocalWorktreeMetadataScanExpectation } from '../tracking-repos/missing-local-worktree-metadata-pruning' @@ -197,9 +199,21 @@ export class MetadataLineageOperations { } ) } + // Why: dropping a row can free the identity key that was vetoing an unrelated row's removal, so + // the metadata prune needs to look again — it is otherwise waiting on evidence (#17775). + invalidateLocalWorktreeMetadataPruneInputs() scheduleSave(this[metadataLineageOperationsContext].scheduling) } + selectProbeableLocalWorktreeMetadataCandidates( + scan: NativeLocalWorktreeMetadataScanExpectation + ): readonly LocalWorktreeMetadataPruneExpectation[] { + return selectProbeableLocalWorktreeMetadataCandidatesOperation( + this[metadataLineageOperationsContext].runtime.state, + scan + ) + } + pruneSessionlessMissingLocalWorktreeMetadataForRepo( scan: NativeLocalWorktreeMetadataScanExpectation, missingMetadata: readonly LocalWorktreeMetadataPruneExpectation[] diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts new file mode 100644 index 00000000000..4c463959bba --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -0,0 +1,37 @@ +import { homedir } from 'node:os' +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' +import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' + +// Simulates a profile created before the dedicated Experimental switch was persisted. +function normalizeLegacyProfile(overrides: Record): PersistedState['settings'] { + const defaults = getDefaultPersistedState(homedir()) + const settings: Partial = { ...defaults.settings } + delete settings.experimentalActivity + delete settings.experimentalAgentDashboardPopout + Object.assign(settings, overrides) + const parsed: PersistedState = { ...defaults, settings: settings as GlobalSettings } + const noop = (): void => {} + const terminal = prepareLoadedTerminalSettings(parsed, noop) + const profile = prepareLoadedProfileSettings(parsed, defaults, noop) + return normalizeLoadedGlobalSettings(parsed, terminal, profile) +} + +describe('retired Agents sidebar setting', () => { + it('does not mark new profiles as migrated', () => { + expect(normalizeLegacyProfile({}).agentsSidebarMigratedFromExperimental).toBe(false) + }) + + it('drops the old visibility setting while preserving migration metadata', () => { + const normalized = normalizeLegacyProfile({ + experimentalActivity: true, + showAgentsSidebar: false + }) + expect('showAgentsSidebar' in normalized).toBe(false) + expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index fb5cc9f1fe8..90678e9f822 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -85,6 +85,10 @@ export function normalizeLoadedGlobalSettings( ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, + // Preserve the legacy opt-in so the one-time introduction copy can target existing users. + agentsSidebarMigratedFromExperimental: + parsed.settings?.agentsSidebarMigratedFromExperimental === true || + migratedExperimentalActivity, // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. compactWorktreeCards: loadedCompactWorktreeCards, experimentalCompactWorktreeCards: undefined, diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 2286e40d54b..39d625c525a 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -35,6 +35,8 @@ export function normalizeLoadedProfileState( const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave) + // Ordered: the host partitions drop the global fields this slice already owns. + const workspaceSession = normalizeLoadedLocalSession(parsed, defaults, markNeedsSave) return { ...defaults, @@ -69,9 +71,14 @@ export function normalizeLoadedProfileState( markNeedsSave ), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + workspaceSession, // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + workspaceSessionsByHostId: normalizeLoadedHostSessions( + parsed, + defaults, + workspaceSession, + markNeedsSave + ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 2b133d486b7..16c5b424def 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -41,11 +41,13 @@ export function normalizeLoadedLocalSession( export function normalizeLoadedHostSessions( parsed: PersistedState, defaults: PersistedState, + localSession: WorkspaceSessionState, markNeedsSave: () => void ): PersistedState['workspaceSessionsByHostId'] { const { partitions, repaired } = parseWorkspaceSessionsByHostId( parsed.workspaceSessionsByHostId, - defaults.workspaceSession + defaults.workspaceSession, + localSession ) if (repaired) { // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts index 2410e68be54..69e0d28d8c5 100644 --- a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -62,7 +62,7 @@ export function prepareLoadedProfileSettings( ): PreparedLoadedProfileSettings { const experimentalActivityDefaultedOffForAllUsers = parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true - // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. + // Why: preserve the legacy rollout boundary while loading profiles created before the Agents tab graduated. const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers ? (parsed.settings?.experimentalActivity ?? false) : false diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 9d6e476ebe5..c112b4a9ba0 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -161,7 +161,8 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom // Why: fsync before rename, then fsync the directory; see writeFileDurable. const handle = await open(tmpFile, 'w') try { - await handle.writeFile(payload, 'utf-8') + // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. + await handle.writeFile(payload) await handle.sync() } finally { await handle.close() diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index c7bdbd9de37..535108845e1 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -13,6 +13,7 @@ import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/proje import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue' +import { retireLocalWorktreeMetadataPruneStateForRepo } from '../../local-worktree-metadata-prune-gate' import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' @@ -221,6 +222,9 @@ export function pruneWorktreeStateForRepo( hostId, (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) ) + // Why: this drops metadata, lineage, leases and session owners in bulk, which can unpin rows in + // other repos, and a full removal retires this repo's own gate state (#17775). + retireLocalWorktreeMetadataPruneStateForRepo(id, hostId) } export function pruneMobileClientTabSelections( diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts new file mode 100644 index 00000000000..e58d0280bae --- /dev/null +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -0,0 +1,184 @@ +/** + * The bar for this change is "the bytes on disk did not move". Every case below runs the exact + * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and + * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write + * guard and a drifting payload is corrupted persisted state. + */ +import { createHash, randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' + +/** Verbatim from state-serialization-secret-handling.ts before this change. */ +function previousImplementation( + serialized: string, + secretSubs: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string } { + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1').update(degradedPrefix).update(hashInput).digest('hex') + // `handle.writeFile(payload, 'utf-8')` is what turned the string into bytes. + return { payload: Buffer.from(payload, 'utf8'), stateHash } +} + +function expectIdenticalToPrevious( + serialized: string, + subs: readonly SecretSentinelSubstitution[], + degradedPrefix = '' +): void { + const before = previousImplementation(serialized, subs, degradedPrefix) + const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix) + expect(after.payload.equals(before.payload)).toBe(true) + expect(after.stateHash).toBe(before.stateHash) +} + +function sentinel(): string { + return `orca-secret-slot-${randomUUID()}` +} + +describe('applySecretSentinelSubstitutions', () => { + it('produces bytes and a hash identical to the previous implementation', () => { + const subs: SecretSentinelSubstitution[] = [ + { sentinel: sentinel(), blob: 'djEwY2lwaGVy', hashValue: 'cookie-value' }, + { + sentinel: sentinel(), + // Regex-special *and* JSON-escapable, which is the pair that breaks a naive rewrite: + // `$&` would splice the match back in under string-form replace, and the backslash and + // quote have to survive `JSON.stringify(...).slice(1, -1)` unchanged. + blob: 'A+/=$&$1$`\\x "quoted" |.*?[](){}^', + hashValue: 'http://proxy.example:8080/?a=b&c=$&' + }, + { sentinel: sentinel(), blob: '', hashValue: 'https://kagi.com/session?t=abc' } + ] + const state = { + settings: { opencodeSessionCookie: subs[0].sentinel, httpProxyUrl: subs[1].sentinel }, + ui: { browserKagiSessionLink: subs[2].sentinel }, + // Adjacent content that must not shift: a near-miss prefix, and JSON escapes either side. + noise: ['orca-secret-slot-', 'a\\b"c\n\t', subs[0].sentinel.slice(0, -1)] + } + expectIdenticalToPrevious(JSON.stringify(state), subs) + }) + + it('stays identical when the state holds multi-byte and escaped characters', () => { + const subs: SecretSentinelSubstitution[] = [ + { sentinel: sentinel(), blob: 'blob-é', hashValue: 'plain-é' }, + { sentinel: sentinel(), blob: '😀', hashValue: '中文' } + ] + const state = { + // Segment boundaries land next to these, so a wrong split would corrupt the encode. + before: 'é中文😀', + a: subs[0].sentinel, + between: '😀

', + b: subs[1].sentinel, + after: '😀' + } + expectIdenticalToPrevious(JSON.stringify(state), subs) + }) + + it('stays identical with no substitutions and with the degraded-storage prefix', () => { + const state = JSON.stringify({ settings: { httpProxyUrl: '' }, big: 'x'.repeat(4096) }) + expectIdenticalToPrevious(state, []) + expectIdenticalToPrevious(state, [], 'safeStorage-degraded\0') + + const subs = [{ sentinel: sentinel(), blob: 'b', hashValue: 'h' }] + expectIdenticalToPrevious( + JSON.stringify({ s: subs[0].sentinel }), + subs, + 'safeStorage-degraded\0' + ) + }) + + it('escapes regex metacharacters in the sentinel itself', () => { + // Not reachable from a UUID sentinel, but the alternation must not be able to become a pattern. + const subs = [{ sentinel: 'a.b*c(d)|e[f]', blob: 'BLOB', hashValue: 'HASH' }] + const serialized = JSON.stringify({ real: subs[0].sentinel, decoy: 'axbxxcXdX_eXfX' }) + expectIdenticalToPrevious(serialized, subs) + expect( + applySecretSentinelSubstitutions(serialized, subs, '').payload.toString('utf8') + ).toContain('axbxxcXdX_eXfX') + }) + + it('substitutes every occurrence when a sentinel repeats', () => { + // Cannot happen today (a sentinel is a UUID minted after the state is assembled, so it appears + // exactly once), but the old first-match-only `String.replace` would have written a raw + // sentinel to disk in place of a secret if it ever did. The alternation is global instead. + const subs = [{ sentinel: sentinel(), blob: 'CIPHER', hashValue: 'PLAIN' }] + const serialized = JSON.stringify({ a: subs[0].sentinel, b: subs[0].sentinel }) + const { payload } = applySecretSentinelSubstitutions(serialized, subs, '') + expect(payload.toString('utf8')).toBe(JSON.stringify({ a: 'CIPHER', b: 'CIPHER' })) + expect(payload.toString('utf8')).not.toContain(subs[0].sentinel) + }) + + it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => { + const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({ + sentinel: sentinel(), + blob: 'CIPHERTEXT', + hashValue: 'plaintext' + })) + const serialized = JSON.stringify({ + pad: 'x'.repeat(200_000), + a: subs[0].sentinel, + b: subs[1].sentinel, + c: subs[2].sentinel + }) + const FULL_STATE = 100_000 + + // Both costs are observable at their sources: a `String.replace` whose receiver is the whole + // state allocates another copy of it, and every string handed to `Buffer.from` or `hash.update` + // is one full UTF-8 encode pass on the main thread. + const counted = (run: () => unknown): { fullStateReplaces: number; encodedChars: number } => { + const realReplace = String.prototype.replace + const realBufferFrom = Buffer.from + const hashProto = Object.getPrototypeOf(createHash('sha1')) as { + update: (...args: unknown[]) => unknown + } + const realUpdate = hashProto.update + const counts = { fullStateReplaces: 0, encodedChars: 0 } + String.prototype.replace = function (this: string, ...args: unknown[]) { + if (this.length >= FULL_STATE) { + counts.fullStateReplaces++ + } + return realReplace.apply(this, args as never) + } as typeof String.prototype.replace + Buffer.from = function (...args: unknown[]) { + if (typeof args[0] === 'string') { + counts.encodedChars += args[0].length + } + return (realBufferFrom as (...a: unknown[]) => Buffer).apply(Buffer, args) + } as typeof Buffer.from + hashProto.update = function (this: unknown, ...args: unknown[]) { + if (typeof args[0] === 'string') { + counts.encodedChars += args[0].length + } + return realUpdate.apply(this, args) + } + try { + run() + } finally { + String.prototype.replace = realReplace + Buffer.from = realBufferFrom + hashProto.update = realUpdate + } + return counts + } + + const before = counted(() => previousImplementation(serialized, subs, '')) + const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, '')) + + // Two `String.replace` calls over the whole state per sentinel — payload and hash input. + expect(before.fullStateReplaces).toBe(subs.length * 2) + expect(after.fullStateReplaces).toBe(0) + // The old path encoded the state twice: once for sha1, once for the file write. + expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9) + expect(after.encodedChars).toBeLessThan(serialized.length * 1.1) + expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9) + }) +}) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts new file mode 100644 index 00000000000..afcdddafa77 --- /dev/null +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import { escapeRegex } from '../../../shared/string-utils' + +export type SecretSentinelSubstitution = { + /** The `orca-secret-slot-` placeholder standing in the serialized state. */ + sentinel: string + /** What the on-disk payload gets: the ciphertext. */ + blob: string + /** What the guard hash gets: a value stable across non-deterministic encryption. */ + hashValue: string +} + +/** + * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the + * guard hash from the same encoded segments. + * + * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each + * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so + * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when + * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three + * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space. + * + * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same + * buffers feed both the payload and the hash — 1 full-state string, 1 encode. + * + * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped + * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte + * sequence it saw when it was handed one concatenated string. + */ +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string } { + const hash = createHash('sha1').update(degradedPrefix) + if (substitutions.length === 0) { + const payload = Buffer.from(serialized, 'utf8') + return { payload, stateHash: hash.update(payload).digest('hex') } + } + + const replacementBySentinel = new Map() + const alternatives: string[] = [] + for (const { sentinel, blob, hashValue } of substitutions) { + // Preserved from the loop this replaces: both the search key and the replacements are the + // JSON-escaped forms, because that is what `serialized` actually contains. + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + if (replacementBySentinel.has(escapedSentinel)) { + continue + } + alternatives.push(escapeRegex(escapedSentinel)) + replacementBySentinel.set(escapedSentinel, { + blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'), + hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8') + }) + } + + // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly + // once: a single pass that substitutes every occurrence cannot leave one behind on disk. + const pattern = new RegExp(alternatives.join('|'), 'g') + const chunks: Buffer[] = [] + let cursor = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(serialized)) !== null) { + // Non-null: the alternation is built from exactly the map's keys. + const replacement = replacementBySentinel.get(match[0])! + // A sliced substring, so this does not copy the state; the encode below is its only pass. + const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8') + chunks.push(literal, replacement.blob) + hash.update(literal) + hash.update(replacement.hashValue) + cursor = match.index + match[0].length + } + const tail = Buffer.from(serialized.slice(cursor), 'utf8') + chunks.push(tail) + hash.update(tail) + + return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') } +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 43b12531a02..6a5743f60ea 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -13,6 +13,7 @@ import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { removeWorkspaceSessionOwner, workspaceSessionPartitionIdsForHost @@ -132,6 +133,9 @@ export function removeWorkspaceSessionOwnerInPartition( if (!session) { return } + // Why: a session was the last thing pinning some dangling metadata row; releasing it is the + // evidence the metadata prune waits for, and there is no other signal that it happened (#17775). + invalidateLocalWorktreeMetadataPruneInputs() if (resolved === LOCAL_EXECUTION_HOST_ID) { owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSession = session } else { diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index c9d81f071a5..61689ea0f13 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' import { @@ -8,6 +8,10 @@ import { } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' import type { StoreRuntimeState } from './store-runtime-state' type StateSerializationSecretHandlingOperationsRuntime = Pick< @@ -24,7 +28,7 @@ export class StateSerializationSecretHandlingOperations { } buildStateToSave(): { - payload: string + payload: Buffer stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] } { @@ -37,7 +41,7 @@ export class StateSerializationSecretHandlingOperations { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const secretSubs: SecretSentinelSubstitution[] = [] const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] let protectedStorageDegraded = false const encryptToSentinel = (slot: string, plaintext: string): string => { @@ -105,21 +109,14 @@ export class StateSerializationSecretHandlingOperations { // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') + // Substitute each unique sentinel: ciphertext for the on-disk payload, a stable normalized + // value for the guard hash. One pass builds both, so the multi-MB state is never copied per + // sentinel and never encoded twice. + const { payload, stateHash } = applySecretSentinelSubstitutions( + serialized, + secretSubs, + protectedStorageDegraded ? 'safeStorage-degraded\0' : '' + ) return { payload, stateHash, protectedSecretUpdates } } } diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts new file mode 100644 index 00000000000..ee7f26feb3e --- /dev/null +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -0,0 +1,131 @@ +/** + * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt + * per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote + * host partition — and reloads from the file it actually wrote, because the failure this guards + * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until + * something reads the bytes back. + */ +import { mkdtempSync, readFileSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + // Encryption ON, so the secret slots really do mint sentinels and the substitution pass runs. + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`), + decryptString: (value: Buffer) => value.toString().slice(4) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') + +const HOST_ID = 'ssh:user@host' + +const stores: InstanceType[] = [] +afterEach(() => { + for (const store of stores.splice(0)) { + store.flush() + } + vi.restoreAllMocks() +}) + +function openStore(dataFile: string): InstanceType { + const store = new Store({ dataFile }) + stores.push(store) + return store +} + +function session(activeTabId: string): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + // Left null: the load path's deregistered-repo sweep nulls an active worktree whose repo is + // not registered, which would mask what this test is actually about. + activeWorktreeId: null, + activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + // Non-ASCII on purpose: a byte-offset mistake in the encode shows up here first. + browserUrlHistory: [ + { + url: 'https://example.test/é😀', + normalizedUrl: 'https://example.test/é😀', + title: '中文 title', + lastVisitedAt: 17, + visitCount: 3 + } + ] + } as WorkspaceSessionState +} + +describe('persisted state survives a save/load round trip', () => { + it('reloads settings, secrets and both session partitions unchanged', () => { + const dataFile = join( + realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))), + 'orca-data.json' + ) + const written = openStore(dataFile) + written.updateSettings({ + // Three secret slots, i.e. three sentinels in one save — the case the old loop paid 7 copies for. + opencodeSessionCookie: 'cookie-é-value', + httpProxyUrl: 'http://proxy.example:8080/?a=b&c=$&' + }) + written.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=abc' }) + written.setWorkspaceSession(session('local-tab')) + written.setWorkspaceSession(session('remote-tab'), HOST_ID) + written.flush() + + const before = { + settings: written.getSettings(), + ui: written.getUI(), + local: written.getWorkspaceSession(), + remote: written.getWorkspaceSession(HOST_ID) + } + + // The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets. + const bytes = readFileSync(dataFile) + const onDisk = JSON.parse(bytes.toString('utf8')) + expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value') + expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain( + 'cookie-é-value' + ) + expect(bytes.toString('utf8')).not.toContain('orca-secret-slot-') + + const reloaded = openStore(dataFile) + expect(reloaded.getSettings().opencodeSessionCookie).toBe(before.settings.opencodeSessionCookie) + expect(reloaded.getSettings().httpProxyUrl).toBe(before.settings.httpProxyUrl) + expect(reloaded.getUI().browserKagiSessionLink).toBe(before.ui.browserKagiSessionLink) + // `toMatchObject`: the load path spreads session defaults over what was written, so the + // reloaded slice is a superset. Exact deep equality is asserted on the second trip below. + expect(reloaded.getWorkspaceSession()).toMatchObject(before.local) + // The remote partition keeps everything it owns; only globals local already holds are dropped, + // and `browserUrlHistory` comes back at its default from the same spread as before. + expect(reloaded.getWorkspaceSession(HOST_ID).activeTabId).toBe('remote-tab') + expect(reloaded.getWorkspaceSession(HOST_ID).browserUrlHistory).toEqual([]) + + // Deep equality of the whole reloaded state, taken across a second round trip so the assertion + // is not comparing against the first load's one-time settings migrations. + reloaded.flush() + const bytesAfterReload = readFileSync(dataFile) + const again = openStore(dataFile) + expect(again.getSettings()).toEqual(reloaded.getSettings()) + expect(again.getUI()).toEqual(reloaded.getUI()) + expect(again.getWorkspaceSession()).toEqual(reloaded.getWorkspaceSession()) + expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID)) + // ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content. + again.flush() + expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/store-prune-gate-signals.test.ts b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts new file mode 100644 index 00000000000..9c9aa2eb176 --- /dev/null +++ b/src/main/persistence/loading-store/store-prune-gate-signals.test.ts @@ -0,0 +1,105 @@ +/** + * Drives the real `Store`, because the value of the prune gate is entirely in whether the shipping + * write paths signal it. A mutation that unwires the call site survives any test that pokes the gate + * module directly. + */ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') +const { + __resetLocalWorktreeMetadataPruneGateForTests, + isLocalWorktreeMetadataPruneDue, + markLocalWorktreeMetadataPruneStarted +} = await import('../../local-worktree-metadata-prune-gate') + +const REPO_ID = 'repo-1' +const WORKTREE_ID = `${REPO_ID}::/tmp/worktree-a` + +const stores: InstanceType[] = [] + +beforeEach(() => { + __resetLocalWorktreeMetadataPruneGateForTests() +}) + +afterEach(() => { + // Leaving a debounced save armed would write into a temp dir after the test file finishes. + for (const store of stores.splice(0)) { + store.flush() + } + vi.restoreAllMocks() +}) + +function createStore(): InstanceType { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-prune-gate-'))) + const store = new Store({ dataFile: join(dir, 'orca-data.json') }) + stores.push(store) + return store +} + +/** Park the gate the way a completed hygiene pass does. */ +function parkGate(): void { + markLocalWorktreeMetadataPruneStarted(REPO_ID) + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(false) +} + +describe('store signals to the worktree metadata prune gate', () => { + it('re-arms the gate when a session releases its claim on a worktree', () => { + const store = createStore() + store.setWorkspaceSession({ + activeRepoId: REPO_ID, + activeWorktreeId: WORKTREE_ID, + activeTabId: 'tab-1', + tabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1', worktreeId: WORKTREE_ID }] }, + terminalLayoutsByTabId: {} + } as unknown as WorkspaceSessionState) + parkGate() + + store.removeWorkspaceSessionStateForWorktree(WORKTREE_ID) + + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(true) + }) + + it('re-arms the gate when a metadata row is removed', () => { + const store = createStore() + store.setWorktreeMeta(WORKTREE_ID, { displayName: 'a', hostId: 'local' }) + parkGate() + + store.removeWorktreeMeta(WORKTREE_ID) + + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(true) + }) + + it('leaves the gate parked for writes that only add or update a claim', () => { + const store = createStore() + parkGate() + + store.setWorktreeMeta(WORKTREE_ID, { displayName: 'a', hostId: 'local' }) + store.setWorktreeMeta(WORKTREE_ID, { displayName: 'b', hostId: 'local' }) + + // Why this matters: the worktree listing itself stamps metadata on every scan, so a gate that + // re-armed on ordinary writes would restore the storm it exists to stop (#17775). + expect(isLocalWorktreeMetadataPruneDue(REPO_ID)).toBe(false) + }) +}) diff --git a/src/main/persistence/loading-store/workspace-session-partitions.test.ts b/src/main/persistence/loading-store/workspace-session-partitions.test.ts new file mode 100644 index 00000000000..f9697f77997 --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.test.ts @@ -0,0 +1,141 @@ +/** + * Global session fields live in the 'local' slice. Copies of them inside a non-local host partition + * are legacy residue: the split never writes them there and the merge never reads them from there + * unless local has nothing. These tests pin the drop to exactly that condition, keep the renderer's + * merge landing on the same value either way, and re-check the two safety gates that decide which + * global fields may be dropped at all. + */ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types' +import type { WorkspaceDocHistoryEntry } from '../../../shared/workspace-doc-history' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' +import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from '../restoring-sessions/session-worktree-ownership' +import { + HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS, + parseWorkspaceSessionsByHostId +} from './workspace-session-partitions' + +const HOST = 'ssh:target-1' + +function history(url: string): BrowserHistoryEntry[] { + return [{ url, normalizedUrl: url, title: url, lastVisitedAt: 1, visitCount: 1 }] +} + +function docEntry(filePath: string): WorkspaceDocHistoryEntry { + return { + docLocation: { kind: 'workspace-doc', worktreeId: 'repo-1::/tmp/a', filePath }, + title: filePath, + lastVisitedAt: 2, + visitCount: 1 + } +} + +function localSession(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +function parse( + raw: Record, + local?: WorkspaceSessionState +): Partial> { + return parseWorkspaceSessionsByHostId(raw, getDefaultWorkspaceSession(), local).partitions +} + +describe('HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS', () => { + it('only lists fields that are global AND that no worktree-ownership pass follows', () => { + for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) { + // Gate 1: the renderer's split/merge treat it as local-owned, so a non-local copy is dead. + expect(WORKSPACE_SESSION_FIELD_OWNERSHIP[field]).toBe('global') + // Gate 2: `collectPersistedSessionWorktreeOwners` and the deregistered-repo residue sweep + // walk EVERY partition through this table. Anything but 'none' means dropping the field + // could un-own a worktree and get its metadata pruned. + expect(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field]).toBe('none') + } + }) +}) + +describe('parseWorkspaceSessionsByHostId global-field residue', () => { + it('drops a non-local global field the local slice already owns', () => { + const local = localSession({ browserUrlHistory: history('https://local.test') }) + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + browserUrlHistory: history('https://stale.test') + } + }, + local + ) + // Back to the default from the spread, not the 65 KB stale replica. The merge reads this field + // from local whenever local has it, so the renderer still sees `https://local.test` + // (`workspace-session-host-split.test.ts` pins that half of the contract). + expect(partitions[HOST]?.browserUrlHistory).toEqual([]) + }) + + it('retains a non-local global field the local slice does NOT have', () => { + // `workspaceDocHistory` is optional and absent from the defaults, so local can genuinely lack + // it and the merge's fallback to another slice is live. + const local = localSession({}) + expect(local.workspaceDocHistory).toBeUndefined() + const docs = [docEntry('/repo/remote.md')] + const partitions = parse( + { [HOST]: { ...getDefaultWorkspaceSession(), workspaceDocHistory: docs } }, + local + ) + // Retained, so the merge's "fall back to any slice that has it" path still finds a value. + expect(partitions[HOST]?.workspaceDocHistory).toEqual(docs) + }) + + it('drops that same field once the local slice does have it', () => { + const localDocs = [docEntry('/repo/local.md')] + const local = localSession({ workspaceDocHistory: localDocs }) + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + workspaceDocHistory: [docEntry('/repo/stale.md')] + } + }, + local + ) + expect(partitions[HOST]).not.toHaveProperty('workspaceDocHistory') + expect(local.workspaceDocHistory).toEqual(localDocs) + }) + + it('leaves worktree-referencing globals and host-owned fields alone', () => { + const local = localSession({ + browserUrlHistory: history('https://local.test'), + activeWorktreeId: 'repo-1::/tmp/local', + activeTabId: 'local-tab' + }) + const tabs = { 'repo-1::/tmp/a': [] } + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + // A `'direct'` worktree reference the residue sweep reads out of every partition. + activeWorktreeId: 'repo-1::/tmp/a', + // Read on a partition by the mobile terminal projection. + activeTabId: 'remote-tab', + tabsByWorktree: tabs, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 } + } + }, + local + ) + expect(partitions[HOST]?.activeWorktreeId).toBe('repo-1::/tmp/a') + expect(partitions[HOST]?.activeTabId).toBe('remote-tab') + expect(partitions[HOST]?.tabsByWorktree).toEqual(tabs) + expect(partitions[HOST]?.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 4 }) + }) + + it('is a no-op when no local slice is supplied', () => { + const stale = history('https://stale.test') + const partitions = parse({ + [HOST]: { ...getDefaultWorkspaceSession(), browserUrlHistory: stale } + }) + expect(partitions[HOST]?.browserUrlHistory).toEqual(stale) + }) +}) diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts index 50e9d4ae8cc..b6f78479dbf 100644 --- a/src/main/persistence/loading-store/workspace-session-partitions.ts +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -17,11 +17,49 @@ export function workspaceSessionSalvageLogDetails(result: { } } +/** + * Global fields belong to the 'local' slice: the split writes them only there and the merge reads + * them only from there. A copy inside a non-local partition is legacy residue no read can reach — + * stale `browserUrlHistory` replicas alone were 589 KB, 12.7% of a 4.65 MB store, rewritten on + * every save and reparsed on every launch. + * + * Deliberately NOT every field in `GLOBAL_WORKSPACE_SESSION_FIELDS`. Two separate gates disqualify + * the rest, and both are load-bearing: + * - `activeWorktreeId` and `activeWorkspaceKey` are `'direct'` in + * `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND`, and both `collectPersistedSessionWorktreeOwners` + * and the deregistered-repo residue sweep read them out of EVERY partition. Dropping one + * un-owns a worktree, and an un-owned worktree gets its metadata pruned. + * - `activeTabId`, `activeConnectionIdsAtShutdown` and `activeRepoId` have live main-side readers + * on a partition: `isPersistedTerminalLeafActive` falls back to `activeTabId` for the mobile + * projection, and the runtime attach-window handoff unions `activeConnectionIdsAtShutdown`. + * + * `workspace-session-partitions.test.ts` re-checks both gates for every field listed here. + */ +export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [ + 'browserUrlHistory', + 'workspaceDocHistory' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +/** Dropped only where local already holds the field — exactly when the merge's fallback to another + * slice cannot fire. Runs before the defaults spread, so a field the type requires comes back at + * its default rather than going missing. */ +function dropRedundantGlobalFields( + slice: Partial, + local: WorkspaceSessionState | undefined +): void { + for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) { + if (local?.[field] !== undefined) { + delete slice[field] + } + } +} + /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ export function parseWorkspaceSessionsByHostId( raw: unknown, - defaults: WorkspaceSessionState + defaults: WorkspaceSessionState, + localSession?: WorkspaceSessionState ): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { partitions: {}, repaired: raw !== undefined } @@ -50,6 +88,7 @@ export function parseWorkspaceSessionsByHostId( ) repaired = true } + dropRedundantGlobalFields(result.value, localSession) partitions[hostId] = { ...defaults, ...result.value } } return { partitions, repaired } diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 97cd3b2b544..5b7f90cbb8f 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -15,6 +15,8 @@ import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias- import { normalizeWorkspaceSessionPaneIdentities, remapAcknowledgedAgentPaneKeys, + remapActivityClearedAtPaneKeys, + remapManuallyUnreadTurnPaneKeys, remapSshRemotePtyLeaseLeafIds, type WorkspaceSessionPaneIdentityRemap } from '../restoring-sessions/workspace-pane-normalization' @@ -50,10 +52,30 @@ export function setLocalWorkspaceSession( context.runtime.state.ui?.acknowledgedAgentsByPaneKey, normalized.leafIdByInputLeafIdByTabId ) - if (remappedAcknowledgements.changed) { + const remappedActivityCutoffs = remapActivityClearedAtPaneKeys( + context.runtime.state.ui?.activityClearedAtByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + const remappedManualUnread = remapManuallyUnreadTurnPaneKeys( + context.runtime.state.ui?.manuallyUnreadTurnsByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if ( + remappedAcknowledgements.changed || + remappedActivityCutoffs.changed || + remappedManualUnread.changed + ) { context.runtime.state.ui = { ...context.runtime.state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + ...(remappedAcknowledgements.changed + ? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } + : {}), + ...(remappedActivityCutoffs.changed + ? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs } + : {}), + ...(remappedManualUnread.changed + ? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns } + : {}) } } for (const entry of normalized.legacyPaneKeyAliasEntries) { diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.test.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.test.ts new file mode 100644 index 00000000000..c0359efd612 --- /dev/null +++ b/src/main/persistence/restoring-sessions/pane-key-remapping.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { + remapActivityClearedAtPaneKeys, + remapManuallyUnreadTurnPaneKeys +} from './pane-key-remapping' + +const STABLE_LEAF_ID = '00000000-0000-4000-8000-000000000001' + +describe('remapManuallyUnreadTurnPaneKeys', () => { + it('promotes legacy pane keys to the restored stable leaf like clear-completed cutoffs', () => { + const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]]) + const turns = { 'tab-1:pane:1': 42, 'tab-2:pane:9': 7 } + + const result = remapManuallyUnreadTurnPaneKeys(turns, remap) + + expect(result.changed).toBe(true) + expect(result.turns).toEqual({ [makePaneKey('tab-1', STABLE_LEAF_ID)]: 42, 'tab-2:pane:9': 7 }) + // Same remap contract as the cutoffs so the two never drift after a session restore. + expect(remapActivityClearedAtPaneKeys(turns, remap).cutoffs).toEqual(result.turns) + }) + + it('reports no change for empty or already-stable records', () => { + const remap = new Map([['tab-1', new Map([['pane:1', STABLE_LEAF_ID]])]]) + expect(remapManuallyUnreadTurnPaneKeys(undefined, remap).changed).toBe(false) + expect(remapManuallyUnreadTurnPaneKeys({}, remap).changed).toBe(false) + const stable = { [makePaneKey('tab-1', STABLE_LEAF_ID)]: 1 } + expect(remapManuallyUnreadTurnPaneKeys(stable, remap)).toEqual({ + turns: stable, + changed: false + }) + }) +}) diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.ts new file mode 100644 index 00000000000..4f3440f087e --- /dev/null +++ b/src/main/persistence/restoring-sessions/pane-key-remapping.ts @@ -0,0 +1,75 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' + +type PaneLeafRemap = Map> + +function remapPaneKeys( + values: Record | undefined, + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { values: Record | undefined; changed: boolean } { + if (!values || Object.keys(values).length === 0) { + return { values, changed: false } + } + + let changed = false + const next: Record = {} + const setValue = (paneKey: string, value: T): void => { + const existing = next[paneKey] + next[paneKey] = existing === undefined ? value : (Math.max(existing, value) as T) + } + for (const [paneKey, value] of Object.entries(values)) { + const parsed = parsePaneKey(paneKey) + if (parsed) { + setValue(paneKey, value) + continue + } + + const delimiter = paneKey.indexOf(':') + if (delimiter <= 0 || delimiter === paneKey.length - 1) { + setValue(paneKey, value) + continue + } + + const tabId = paneKey.slice(0, delimiter) + const legacyLeafId = paneKey.slice(delimiter + 1) + const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId) + if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) { + setValue(paneKey, value) + continue + } + + try { + // Carry values over when a legacy leaf is promoted to a UUID. + setValue(makePaneKey(tabId, remappedLeafId), value) + changed = true + } catch { + setValue(paneKey, value) + } + } + + return { values: next, changed } +} + +export function remapAcknowledgedAgentPaneKeys( + acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { + const result = remapPaneKeys(acknowledgements, leafIdByInputLeafIdByTabId) + return { acknowledgements: result.values, changed: result.changed } +} + +export function remapManuallyUnreadTurnPaneKeys( + turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey'], + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { turns: PersistedState['ui']['manuallyUnreadTurnsByPaneKey']; changed: boolean } { + const result = remapPaneKeys(turns, leafIdByInputLeafIdByTabId) + return { turns: result.values, changed: result.changed } +} + +export function remapActivityClearedAtPaneKeys( + cutoffs: PersistedState['ui']['activityClearedAtByPaneKey'], + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { cutoffs: PersistedState['ui']['activityClearedAtByPaneKey']; changed: boolean } { + const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId) + return { cutoffs: result.values, changed: result.changed } +} diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index 2bfafebae9f..cfc26c7bb8a 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -8,7 +8,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import type { SshRemotePtyLease } from '../../../shared/ssh-types' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id' import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids' import { createLazyTerminalTabLookup, @@ -22,6 +22,17 @@ import { migrationUnsupportedEntriesEqual, normalizeLegacyPaneKeyAliasEntries } from './pane-alias-normalization' +import { + remapAcknowledgedAgentPaneKeys, + remapActivityClearedAtPaneKeys, + remapManuallyUnreadTurnPaneKeys +} from './pane-key-remapping' + +export { + remapAcknowledgedAgentPaneKeys, + remapActivityClearedAtPaneKeys, + remapManuallyUnreadTurnPaneKeys +} from './pane-key-remapping' export function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, @@ -220,6 +231,14 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { state.ui?.acknowledgedAgentsByPaneKey, withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds) ) + const remappedActivityCutoffs = remapActivityClearedAtPaneKeys( + state.ui?.activityClearedAtByPaneKey, + withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds) + ) + const remappedManualUnread = remapManuallyUnreadTurnPaneKeys( + state.ui?.manuallyUnreadTurnsByPaneKey, + withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds) + ) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], mergedMigrationUnsupportedEntries @@ -234,7 +253,9 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { !remappedLeases.changed && !migrationUnsupportedChanged && !legacyAliasesChanged && - !remappedAcknowledgements.changed + !remappedAcknowledgements.changed && + !remappedActivityCutoffs.changed && + !remappedManualUnread.changed ) { return { state, @@ -251,11 +272,21 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { sshRemotePtyLeases: remappedLeases.leases, migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries, - ...(remappedAcknowledgements.changed + ...(remappedAcknowledgements.changed || + remappedActivityCutoffs.changed || + remappedManualUnread.changed ? { ui: { ...state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + ...(remappedAcknowledgements.changed + ? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } + : {}), + ...(remappedActivityCutoffs.changed + ? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs } + : {}), + ...(remappedManualUnread.changed + ? { manuallyUnreadTurnsByPaneKey: remappedManualUnread.turns } + : {}) } } : {}) @@ -265,50 +296,3 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } - -export function remapAcknowledgedAgentPaneKeys( - acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], - leafIdByInputLeafIdByTabId: Map> -): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { - if (!acknowledgements || Object.keys(acknowledgements).length === 0) { - return { acknowledgements, changed: false } - } - - let changed = false - const next: NonNullable = {} - const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => { - const existing = next[paneKey] - next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt) - } - for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) { - const parsed = parsePaneKey(paneKey) - if (parsed) { - setAcknowledgement(paneKey, acknowledgedAt) - continue - } - - const delimiter = paneKey.indexOf(':') - if (delimiter <= 0 || delimiter === paneKey.length - 1) { - setAcknowledgement(paneKey, acknowledgedAt) - continue - } - - const tabId = paneKey.slice(0, delimiter) - const legacyLeafId = paneKey.slice(delimiter + 1) - const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId) - if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) { - setAcknowledgement(paneKey, acknowledgedAt) - continue - } - - try { - // Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread. - setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt) - changed = true - } catch { - setAcknowledgement(paneKey, acknowledgedAt) - } - } - - return { acknowledgements: next, changed } -} diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 93bb7de2cea..c25315e4986 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import type { Automation, AutomationCreateInput, @@ -262,5 +263,7 @@ export function deleteAutomation( operations.state.automationRuns = (operations.state.automationRuns ?? []).filter( (entry) => entry.automationId !== id ) + // Why: the automation and its unfinished runs were pinning their workspace; both are gone (#17775). + invalidateLocalWorktreeMetadataPruneInputs() operations.flush() } diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.test.ts b/src/main/persistence/scheduling-automations/automation-run-operations.test.ts new file mode 100644 index 00000000000..c2ae00c02b6 --- /dev/null +++ b/src/main/persistence/scheduling-automations/automation-run-operations.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { listAutomationRunsPage } from './automation-run-operations' + +function stateWithRuns(runs: { id: string; createdAt: number }[]): PersistedState { + return { + automationRuns: runs.map((run) => ({ ...run, automationId: 'a1' })) + } as PersistedState +} + +describe('listAutomationRunsPage', () => { + it('returns a bounded, newest-first page and an opaque continuation cursor', () => { + const state = stateWithRuns([ + { id: 'old', createdAt: 1 }, + { id: 'new', createdAt: 3 }, + { id: 'middle', createdAt: 2 } + ]) + + const first = listAutomationRunsPage(state, 'a1', 2) + expect(first.runs.map((run) => run.id)).toEqual(['new', 'middle']) + expect(first.nextCursor).not.toBeNull() + + expect(listAutomationRunsPage(state, 'a1', 2, first.nextCursor ?? undefined)).toEqual( + expect.objectContaining({ + runs: [expect.objectContaining({ id: 'old' })], + nextCursor: null + }) + ) + }) + + it('keeps the window stable when a newer run lands between pages', () => { + const state = stateWithRuns([ + { id: 'r1', createdAt: 1 }, + { id: 'r2', createdAt: 2 }, + { id: 'r3', createdAt: 3 } + ]) + + const first = listAutomationRunsPage(state, 'a1', 2) + expect(first.runs.map((run) => run.id)).toEqual(['r3', 'r2']) + + state.automationRuns = [ + ...state.automationRuns, + { id: 'r4', automationId: 'a1', createdAt: 4 } as PersistedState['automationRuns'][number] + ] + + const second = listAutomationRunsPage(state, 'a1', 2, first.nextCursor ?? undefined) + expect(second.runs.map((run) => run.id)).toEqual(['r1']) + expect(second.nextCursor).toBeNull() + }) + + it('resumes after a pruned boundary run instead of restarting the page', () => { + const state = stateWithRuns([ + { id: 'r1', createdAt: 1 }, + { id: 'r2', createdAt: 2 }, + { id: 'r3', createdAt: 3 } + ]) + const first = listAutomationRunsPage(state, 'a1', 2) + + state.automationRuns = state.automationRuns.filter((run) => run.id !== 'r2') + + expect( + listAutomationRunsPage(state, 'a1', 2, first.nextCursor ?? undefined).runs.map( + (run) => run.id + ) + ).toEqual(['r1']) + }) + + it('keeps runs tied on createdAt when the boundary run is pruned', () => { + const state = stateWithRuns([ + { id: 'r2', createdAt: 10 }, + { id: 'r1', createdAt: 10 }, + { id: 'r0', createdAt: 5 } + ]) + const first = listAutomationRunsPage(state, 'a1', 1) + expect(first.runs.map((run) => run.id)).toEqual(['r1']) + + state.automationRuns = state.automationRuns.filter((run) => run.id !== 'r1') + + expect( + listAutomationRunsPage(state, 'a1', 2, first.nextCursor ?? undefined).runs.map( + (run) => run.id + ) + ).toEqual(['r2', 'r0']) + }) + + it('still honours a legacy offset cursor issued before the upgrade', () => { + const state = stateWithRuns([ + { id: 'r1', createdAt: 1 }, + { id: 'r2', createdAt: 2 }, + { id: 'r3', createdAt: 3 } + ]) + + expect(listAutomationRunsPage(state, 'a1', 2, '2').runs.map((run) => run.id)).toEqual(['r1']) + }) +}) diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index 639fda7b836..0dc9e61731a 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -1,8 +1,11 @@ import { randomUUID } from 'node:crypto' +import { isFinalAutomationRunStatus } from '../../../shared/automations-types' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import type { Automation, AutomationDispatchResult, AutomationRun, + AutomationRunsPage, AutomationRunTrigger } from '../../../shared/automations-types' import type { PersistedState } from '../../../shared/persisted-state-types' @@ -10,6 +13,10 @@ import { nextAutomationRunNumber, pruneAutomationRuns } from '../../../shared/automation-run-retention' +import { + compareAutomationRunsNewestFirst, + paginateAutomationRuns +} from '../../../shared/automation-run-cursor' import { normalizeAutomationPrecheckResult, normalizeAutomationRunOutputSnapshot, @@ -34,14 +41,27 @@ function touchAutomation(state: PersistedState, automationId: string, now: numbe ) } -export function listAutomationRuns(state: PersistedState, automationId?: string): AutomationRun[] { +function sortedAutomationRuns(state: PersistedState, automationId?: string): AutomationRun[] { const runs = state.automationRuns ?? [] return [...(automationId ? runs.filter((run) => run.automationId === automationId) : runs)] .map((run) => ({ ...run, precheckResult: normalizeAutomationPrecheckResult(run.precheckResult) })) - .sort((left, right) => right.createdAt - left.createdAt) + .sort(compareAutomationRunsNewestFirst) +} + +export function listAutomationRuns(state: PersistedState, automationId?: string): AutomationRun[] { + return sortedAutomationRuns(state, automationId) +} + +export function listAutomationRunsPage( + state: PersistedState, + automationId: string | undefined, + limit = 100, + cursor?: string +): AutomationRunsPage { + return paginateAutomationRuns(sortedAutomationRuns(state, automationId), limit, cursor) } export function createAutomationRun( @@ -182,6 +202,10 @@ export function updateAutomationRun( operations.state.automationRuns = operations.state.automationRuns.map((run) => run.id === result.runId ? updated : run ) + if (!isFinalAutomationRunStatus(current.status) && isFinalAutomationRunStatus(updated.status)) { + // Why: only a non-final run pins its workspace, so finishing releases the claim (#17775). + invalidateLocalWorktreeMetadataPruneInputs() + } touchAutomation(operations.state, updated.automationId, now) operations.flush() return updated diff --git a/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts index fca925d0986..a2da7e070a8 100644 --- a/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts +++ b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts @@ -203,6 +203,39 @@ function aliasesStillMatch( }) } +/** + * Whether the local host is structurally allowed to drop this row, ignoring concurrent-change checks. + * + * Pure over persisted state — no filesystem, no expectation — so a caller can decide *before* paying + * for a `stat` whether a delete could ever accept the row. Several of these predicates reject the + * same row on every pass forever (a locator also known on a remote host keeps a second alias; a + * legacy row pinned to another host; identity rows that drifted or dangle), which is what turned the + * prune into an unbounded no-progress loop in #17775. + */ +export function isLocallyRemovableWorktreeMetadataRow( + state: PersistedState, + worktreeId: string, + currentAliases: readonly MetadataAliasEntry[] +): boolean { + const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, worktreeId) + if (currentAliases.some(([alias]) => alias !== localAlias)) { + return false + } + const legacy = state.worktreeMeta[worktreeId] + const identityKeys = state.worktreeIdentityAliases?.[localAlias] + if (identityKeys && identityKeys.length !== 1) { + return false + } + const canonical = identityKeys?.[0] ? state.worktreeMetaByIdentity?.[identityKeys[0]] : undefined + return !( + (legacy?.hostId && legacy.hostId !== LOCAL_EXECUTION_HOST_ID) || + (canonical?.hostId && canonical.hostId !== LOCAL_EXECUTION_HOST_ID) || + (identityKeys && !canonical) || + (legacy && canonical && !isDeepStrictEqual(legacy, canonical)) || + (!legacy && !canonical) + ) +} + export function removeRevalidatedLocalWorktreeMetadata( state: PersistedState, expected: LocalWorktreeMetadataPruneExpectation, @@ -211,29 +244,13 @@ export function removeRevalidatedLocalWorktreeMetadata( ): boolean { if ( !rowStillMatches(state.worktreeMeta, expected.worktreeId, expected.expectedLegacy) || - !aliasesStillMatch(state, expected, currentAliases) + !aliasesStillMatch(state, expected, currentAliases) || + !isLocallyRemovableWorktreeMetadataRow(state, expected.worktreeId, currentAliases) ) { return false } const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, expected.worktreeId) - if (currentAliases.some(([alias]) => alias !== localAlias)) { - return false - } - const legacy = state.worktreeMeta[expected.worktreeId] const identityKeys = state.worktreeIdentityAliases?.[localAlias] - if (identityKeys && identityKeys.length !== 1) { - return false - } - const canonical = identityKeys?.[0] ? state.worktreeMetaByIdentity?.[identityKeys[0]] : undefined - if ( - (legacy?.hostId && legacy.hostId !== LOCAL_EXECUTION_HOST_ID) || - (canonical?.hostId && canonical.hostId !== LOCAL_EXECUTION_HOST_ID) || - (identityKeys && !canonical) || - (legacy && canonical && !isDeepStrictEqual(legacy, canonical)) || - (!legacy && !canonical) - ) { - return false - } if (identityKeys?.[0]) { removedIdentityKeys?.add(identityKeys[0]) } diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts index 3ab380bdd8e..0e03a560a17 100644 --- a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts @@ -13,6 +13,7 @@ import { } from '../restoring-sessions/session-worktree-ownership' import { indexMetadataAliasesForWorktreeIds, + isLocallyRemovableWorktreeMetadataRow, removeRevalidatedLocalWorktreeMetadata, type LocalWorktreeMetadataPruneExpectation, type NativeLocalWorktreeMetadataScanExpectation @@ -108,6 +109,42 @@ function isValidCandidateId( ) } +/** + * The captured candidates a delete could still accept, decided without touching the disk. + * + * Why this exists: the caller `stat`s every candidate whose path Git no longer lists, then discovers + * here that most of them are refused anyway — pinned by a persisted session, or structurally + * unremovable on this host. Those verdicts are pure functions of persisted state, so paying for the + * filesystem first inverts the cheap and expensive halves of the decision. On a store with ~1.4k + * dangling rows that was the bulk of a permanent `stat` storm (#17775). + * + * Deliberately advisory: `pruneSessionlessMissingLocalWorktreeMetadataForRepo` re-checks everything + * authoritatively against the capture, so a disagreement here costs at most a wasted `stat` or a row + * lingering one more pass — it can never widen what gets deleted. + */ +export function selectProbeableLocalWorktreeMetadataCandidates( + state: PersistedState, + scan: NativeLocalWorktreeMetadataScanExpectation, + platform = process.platform +): readonly LocalWorktreeMetadataPruneExpectation[] { + const candidateIds = new Set(scan.metadata.map(({ worktreeId }) => worktreeId)) + if (candidateIds.size === 0) { + return scan.metadata + } + const sessionOwners = collectPersistedWorkspaceOwners(state, candidateIds, platform) + const aliasesByWorktreeId = indexMetadataAliasesForWorktreeIds(state, candidateIds) + return scan.metadata.filter( + ({ worktreeId }) => + isValidCandidateId(scan.repo.id, worktreeId, platform) && + !sessionOwners.has(worktreeId) && + isLocallyRemovableWorktreeMetadataRow( + state, + worktreeId, + aliasesByWorktreeId.get(worktreeId) ?? [] + ) + ) +} + export function pruneSessionlessMissingLocalWorktreeMetadataForRepo( state: PersistedState, scan: NativeLocalWorktreeMetadataScanExpectation, diff --git a/src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts b/src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts new file mode 100644 index 00000000000..380d0cba1fd --- /dev/null +++ b/src/main/persistence/tracking-repos/probeable-local-worktree-metadata-candidates.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { + captureNativeLocalWorktreeMetadataScanExpectation, + pruneSessionlessMissingLocalWorktreeMetadataForRepo, + selectProbeableLocalWorktreeMetadataCandidates +} from './missing-local-worktree-metadata-pruning' + +const REPO_ID = 'repo-1' + +function makeRepo(): Repo { + return { + id: REPO_ID, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } +} + +function makeMeta(worktreeId: string, overrides: Partial = {}): WorktreeMeta { + return { + instanceId: `instance-${worktreeId}`, + hostId: 'local', + displayName: worktreeId, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeState(): PersistedState { + const state = getDefaultPersistedState('/home/test') + state.repos = [makeRepo()] + return state +} + +function probeableIds(state: PersistedState): string[] { + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + return selectProbeableLocalWorktreeMetadataCandidates(state, scan, 'linux').map( + ({ worktreeId }) => worktreeId + ) +} + +describe('selectProbeableLocalWorktreeMetadataCandidates', () => { + it('keeps an ordinary sessionless local row', () => { + const state = makeState() + const id = `${REPO_ID}::/workspace/gone` + state.worktreeMeta[id] = makeMeta(id) + + expect(probeableIds(state)).toEqual([id]) + }) + + it('drops a row a persisted session still pins', () => { + const state = makeState() + const pinned = `${REPO_ID}::/workspace/pinned` + const free = `${REPO_ID}::/workspace/free` + state.worktreeMeta[pinned] = makeMeta(pinned) + state.worktreeMeta[free] = makeMeta(free) + state.ui.lastActiveWorktreeId = pinned + + expect(probeableIds(state)).toEqual([free]) + }) + + it('drops a row whose locator is also known on a remote host', () => { + const state = makeState() + const shared = `${REPO_ID}::/workspace/shared` + const free = `${REPO_ID}::/workspace/free` + state.worktreeMeta[shared] = makeMeta(shared) + state.worktreeMeta[free] = makeMeta(free) + state.worktreeIdentityAliases = { [`ssh:box|${shared}`]: ['identity-1'] } + + expect(probeableIds(state)).toEqual([free]) + }) + + it('drops a row pinned to another execution host', () => { + const state = makeState() + const remote = `${REPO_ID}::/workspace/remote` + state.worktreeMeta[remote] = makeMeta(remote, { hostId: 'ssh:box' }) + + expect(probeableIds(state)).toEqual([]) + }) + + it('never widens what the authoritative prune would remove', () => { + const state = makeState() + const ids = [ + `${REPO_ID}::/workspace/free`, + `${REPO_ID}::/workspace/pinned`, + `${REPO_ID}::/workspace/remote` + ] + state.worktreeMeta[ids[0]] = makeMeta(ids[0]) + state.worktreeMeta[ids[1]] = makeMeta(ids[1]) + state.worktreeMeta[ids[2]] = makeMeta(ids[2], { hostId: 'ssh:box' }) + state.ui.lastActiveWorktreeId = ids[1] + + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + const selected = selectProbeableLocalWorktreeMetadataCandidates(state, scan, 'linux') + // Feeding the unfiltered capture to the authoritative prune must reach the same verdict. + const removed = pruneSessionlessMissingLocalWorktreeMetadataForRepo( + state, + scan, + scan.metadata, + 'linux' + ) + + expect(selected.map(({ worktreeId }) => worktreeId)).toEqual(removed) + }) +}) diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index c5fae19e524..6cf8854995d 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -3,6 +3,9 @@ import { encodePowerShellCommand, getPowerShellOsc133Bootstrap } from './powershell-osc133-bootstrap' +import { getShellLaunchConfig } from './daemon/shell-ready' +import { resolveWindowsShellLaunchArgs } from './providers/windows-shell-args' +import { STARTUP_COMMAND_FEATURES } from './shell-startup-launch-intent-fixtures' describe('PowerShell OSC 133 bootstrap', () => { it('wraps prompt/readline without bypassing profiles or execution policy', () => { @@ -44,4 +47,31 @@ describe('PowerShell OSC 133 bootstrap', () => { Buffer.from('Write-Output ok', 'utf16le').toString('base64') ) }) + + // Why pinned: the MDE review (see powershell-osc133-bootstrap.ts) declined a + // switch to -Command. Any future delivery shape must still hand PowerShell this + // payload byte for byte -- comments, quotes, `$` and newlines included. + describe.each([ + [ + 'daemon shell-ready', + () => getShellLaunchConfig('powershell.exe', STARTUP_COMMAND_FEATURES).args ?? [] + ], + [ + 'windows shell args', + () => resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\repo', 'C:\\repo').shellArgs + ] + ])('%s PowerShell launch', (_name, getArgs) => { + it('delivers the bootstrap unmangled', () => { + const args = getArgs() + const encodedIndex = args.indexOf('-EncodedCommand') + + expect(encodedIndex).toBeGreaterThanOrEqual(0) + expect(args).not.toContain('-Command') + expect(args).not.toContain('-ExecutionPolicy') + + const delivered = Buffer.from(args[encodedIndex + 1] ?? '', 'base64').toString('utf16le') + + expect(delivered.startsWith(getPowerShellOsc133Bootstrap())).toBe(true) + }) + }) }) diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 1f356b1c332..f776521d498 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -2,6 +2,39 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' import { getPowerShellCodexShellLaunchPreflight } from './pty/codex-shell-launch-preflight' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' +/** + * Why every PTY site delivers this payload as `-EncodedCommand` and keeps doing so. + * + * An MDE report named the base64 a contributing "suspicious PowerShell" signal and + * pointed at VS Code as the counter-example. VS Code and its forks actually ship + * `["-noexit","-command",'try { . "{0}\\...\\shellIntegration.ps1" } catch {}']` -- a + * one-liner that dot-sources a *file*, not inline script. Dot-sourcing is + * execution-policy gated; inline text is not. Measured on Windows 11: + * + * policy dot-source .ps1 -Command inline -EncodedCommand + * Restricted blocked runs runs + * AllSigned blocked runs runs + * RemoteSigned runs runs runs + * + * So VS Code's shape silently drops OSC 133 -- and with it foreground-process and + * exit-code tracking -- on exactly the locked-down fleets MDE runs on; its `catch {}` + * is that failure being swallowed. + * + * Inline `-Command` does carry this payload intact through node-pty/ConPTY (verified + * on powershell.exe 5.1 and pwsh 7.6.5), so the switch is feasible; it is declined + * because it costs more signal than it removes. No PTY site spells `-ExecutionPolicy + * Bypass`, so base64 is the whole of what would go, and AMSI and script-block logging + * decode it anyway -- nothing is hidden from MDE today. What would change is the + * process command line, which would then carry `$ExecutionContext.SessionState. + * LanguageMode`, a `function Global:prompt` override and `[char]27`-assembled control + * sequences in clear text: higher-signal for command-line heuristics than an opaque + * token with no `Bypass` beside it. + * + * The payload is also not static -- providers/windows-shell-args.ts appends the PTY + * cwd and the queued startup command. #7978 had to move cmd.exe startup commands off + * `/K` to stdin because node-pty's argv escaping mangled their quotes; PowerShell + * never needed that workaround, because `-EncodedCommand` is quoting-proof. + */ const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerShell. # Profiles have already loaded normally by the time -EncodedCommand runs. # Restore managed ownership before the shell-integration compatibility guard. diff --git a/src/main/providers/__fixtures__/real-agent-rows.json.gz b/src/main/providers/__fixtures__/real-agent-rows.json.gz new file mode 100644 index 00000000000..3bd62f9eda3 Binary files /dev/null and b/src/main/providers/__fixtures__/real-agent-rows.json.gz differ diff --git a/src/main/providers/agent-foreground-process-batch.test.ts b/src/main/providers/agent-foreground-process-batch.test.ts index 784a62c3376..8baa37a7499 100644 --- a/src/main/providers/agent-foreground-process-batch.test.ts +++ b/src/main/providers/agent-foreground-process-batch.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' +import { parseStrictProcessTableRows } from '../../shared/process-table-snapshot' import { buildProcessTableIndex, - parseStrictProcessTableRows, type ProcessTableIndexStats -} from '../../shared/process-table-snapshot' +} from '../../shared/process-table-index' import { resolveAgentForegroundProcessesBatch, resolveAgentForegroundProcessesFromIndex diff --git a/src/main/providers/agent-foreground-process-batch.ts b/src/main/providers/agent-foreground-process-batch.ts index ea89005d87f..2e0036bb603 100644 --- a/src/main/providers/agent-foreground-process-batch.ts +++ b/src/main/providers/agent-foreground-process-batch.ts @@ -1,20 +1,21 @@ import { isAgentForegroundWrapperProcess, - isExpectedAgentProcess, - recognizeAgentProcessFromCommandLine + isExpectedAgentProcess } from '../../shared/agent-process-recognition' import { getFirstCommandToken } from '../../shared/command-token-scanner' import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' +import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' import { - buildProcessTableIndex, getStrictProcessTableSnapshot, - lookupProcessTableIndex, - scoreForegroundCandidateRow, type ProcessTableIndex, - type ProcessTableIndexStats, type ProcessTableRow } from '../../shared/process-table-snapshot' +import { + buildProcessTableIndex, + lookupProcessTableIndex, + type ProcessTableIndexStats +} from '../../shared/process-table-index' export type BatchedForegroundProcessRequest = { rootPid: number @@ -126,23 +127,15 @@ export function resolveAgentForegroundProcessesFromIndex( if (wrapperFallback && candidates.length !== 1) { return { available: true, processName: null } } - let bestCandidate: (ProcessTableRow & { depth: number }) | null = null - let bestName: ReturnType = null - for (const candidate of candidates) { - const recognized = recognizeAgentProcessFromCommandLine(candidate.command) - if ( - recognized && - (bestCandidate === null || - scoreForegroundCandidateRow(candidate) > scoreForegroundCandidateRow(bestCandidate)) - ) { - bestCandidate = candidate - bestName = recognized - } - } - if (bestCandidate && bestName) { + const selected = selectForegroundProcessCandidate(candidates, allCandidates) + if (selected) { return { available: true, - processName: resolveOuterWrapperForegroundProcess(bestName, bestCandidate, allCandidates) + processName: resolveOuterWrapperForegroundProcess( + selected.recognized, + selected.candidate, + allCandidates + ) } } return { available: true, processName: null } diff --git a/src/main/providers/agent-foreground-process-real-rows.test.ts b/src/main/providers/agent-foreground-process-real-rows.test.ts new file mode 100644 index 00000000000..fd86e207398 --- /dev/null +++ b/src/main/providers/agent-foreground-process-real-rows.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { gunzipSync } from 'node:zlib' +import { describe, expect, it } from 'vitest' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import { resolveAgentForegroundProcessFromPs } from './agent-foreground-process' + +type CapturedRun = { + agent: string + shellPid: number + rows: ProcessTableRow[] +} + +describe('real foreground process captures', () => { + it('resolves all six agents, including omp over its deeper vendor helpers', () => { + const captured = JSON.parse( + gunzipSync(readFileSync(join(__dirname, '__fixtures__', 'real-agent-rows.json.gz'))).toString( + 'utf8' + ) + ) as CapturedRun[] + + expect(captured).toHaveLength(6) + expect( + captured.map(({ agent, shellPid, rows }) => ({ + agent, + processName: resolveAgentForegroundProcessFromPs(rows, shellPid) + })) + ).toEqual([ + { agent: 'claude', processName: 'claude' }, + { agent: 'codex', processName: 'codex' }, + { agent: 'opencode', processName: 'opencode' }, + { agent: 'gemini', processName: 'gemini' }, + { agent: 'grok', processName: 'grok' }, + { agent: 'omp', processName: 'omp' } + ]) + }) +}) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index d171e39a18e..017f88a7f9b 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -5,12 +5,14 @@ import { getProcessTableSnapshot, type ProcessTableRow } from '../../shared/process-table-snapshot' +import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' import { resolveWindowsAgentForegroundProcessWithAvailability, shouldInspectWindowsAgentForeground, type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' import { isShellProcess } from '../../shared/shell-process-detection' +import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection' export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process' export { @@ -42,29 +44,6 @@ type ShellForegroundConfirmationOptions = { | Promise | null> } -function collectDescendants( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (Row & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} - function commandExecutable(command: string): string { const trimmed = command.trim().replace(/^[-]/, '') if (trimmed.startsWith('"') || trimmed.startsWith("'")) { @@ -96,12 +75,12 @@ export async function confirmShellForegroundProcess( } } try { - const rows = await getFreshProcessTableSnapshot() - if (!rows.some((row) => row.pid === shellPid)) { + const index = getProcessTableIndex(await getFreshProcessTableSnapshot()) + const root = index.byPid.get(shellPid) + if (!root) { return false } - const root = rows.find((row) => row.pid === shellPid)! - const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)] + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)] const spawnedShellBasename = executableBasename(spawnedShellProcess) const foregroundShell = tree .filter( @@ -120,13 +99,6 @@ export async function confirmShellForegroundProcess( } } -function candidateScore(row: ProcessTableRow & { depth: number }): number { - // Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer - // them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the - // parent shell but still lets the native child confirm the same identity. - return (row.stat.includes('+') ? 10_000 : 0) + row.depth -} - export async function resolveAgentForegroundProcess( shellPid: number | null | undefined, fallbackProcess: string | null, @@ -178,7 +150,7 @@ export async function resolveAgentForegroundProcessWithAvailability( const rows = options.fresh ? await getFreshProcessTableSnapshot() : await getProcessTableSnapshot() - if (options.fresh && !rows.some((row) => row.pid === shellPid)) { + if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) { return { available: false, processName: fallbackProcess } } return { @@ -191,30 +163,32 @@ export async function resolveAgentForegroundProcessWithAvailability( } } -function resolveAgentForegroundProcessFromPs( - rows: ProcessTableRow[], +export function resolveAgentForegroundProcessFromPs( + rows: readonly ProcessTableRow[], shellPid: number ): string | null { - const shellRow = rows.find((row) => row.pid === shellPid) - const candidates = collectDescendants(rows, shellPid).sort( - (a, b) => candidateScore(b) - candidateScore(a) - ) + // Memoized per snapshot identity, so the caller's own index build is reused. + const index = getProcessTableIndex(rows) + const shellRow = index.byPid.get(shellPid) + const candidates = collectDescendantsFromIndex(index, shellPid) // Why: `+` in `ps stat` marks the process holding the terminal foreground. // The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the // foreground gate; otherwise a stopped agent child still masquerades as live. const foregroundIsKnown = shellRow?.stat.includes('+') === true || candidates.some((candidate) => candidate.stat.includes('+')) - for (const candidate of candidates) { - if (foregroundIsKnown && !candidate.stat.includes('+')) { - continue - } - const recognized = recognizeAgentProcessFromCommandLine(candidate.command) - if (recognized) { - // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. - return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) - } + const foregroundCandidates = foregroundIsKnown + ? candidates.filter((candidate) => candidate.stat.includes('+')) + : candidates + // Keep the complete process tree for ancestry checks. A recognized agent can + // sit above a non-foreground helper before another recognized process; the + // helper is filtered from selection but must remain traversable. + const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates + const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates) + if (selected) { + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) } return null } diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index e11f8add7cd..61a04fdf024 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -122,6 +122,7 @@ export function getShellLaunchConfig( args: [ '-NoLogo', '-NoExit', + // Why base64 and not -Command: see powershell-osc133-bootstrap.ts (MDE review). '-EncodedCommand', encodePowerShellCommand(getPowerShellOsc133Bootstrap()) ], diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 5649db65534..735393449f2 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,6 +1,7 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' +import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -82,9 +83,10 @@ export function resolveUnixShellPath(shellPath: string): string { * Why: when Electron packages the app via asar, the native spawn-helper * binary may lose its +x permission. This function detects and repairs * that so pty.spawn() does not fail with EACCES on first launch. + * macOS only — no other platform builds or execs the helper. */ export function ensureNodePtySpawnHelperExecutable(): void { - if (didEnsureSpawnHelperExecutable || process.platform === 'win32') { + if (didEnsureSpawnHelperExecutable || !usesNodePtySpawnHelper(process.platform)) { return } didEnsureSpawnHelperExecutable = true diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 65913f0c7e9..b9cb21c3354 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -486,14 +486,16 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(searchResult) }) - it('listFiles sends fs.listFiles request', async () => { + // Why #12547: a monorepo listing does not fit one control-lane frame, so the request opts into + // response streaming. An old relay ignores `__streamResponse` and answers plainly, which is the + // plain-array case each of these asserts. + it('listFiles sends a streamable fs.listFiles request', async () => { mux.request.mockResolvedValue(['src/index.ts', 'package.json']) const result = await provider.listFiles('/home/user/project') - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/home/user/project' }, - { signal: undefined } - ) + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { + rootPath: '/home/user/project', + __streamResponse: true + }) expect(result).toEqual(['src/index.ts', 'package.json']) }) @@ -503,26 +505,22 @@ describe('SshFilesystemProvider', () => { maxResults: 20_000, searchQuery: 'target' }) - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { - rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'], - maxResults: 20_000, - searchQuery: 'target' - }, - { signal: undefined } - ) + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { + rootPath: '/home/user/project', + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000, + searchQuery: 'target', + __streamResponse: true + }) }) it('listFiles omits excludePaths when empty', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { excludePaths: [] }) - expect(mux.request).toHaveBeenCalledWith( - 'fs.listFiles', - { rootPath: '/home/user/project' }, - { signal: undefined } - ) + expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { + rootPath: '/home/user/project', + __streamResponse: true + }) }) it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => { @@ -531,8 +529,8 @@ describe('SshFilesystemProvider', () => { await provider.listFiles('/home/user/project', { signal: controller.signal }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', - { rootPath: '/home/user/project' }, - { signal: controller.signal } + { rootPath: '/home/user/project', __streamResponse: true }, + { signal: controller.signal, timeoutMs: undefined } ) }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 70bb06730f8..f6208ea00e9 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -1,6 +1,7 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader' import { uploadBuffer } from '../ssh/sftp-upload' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import { lstatViaSftp } from './ssh-filesystem-provider-sftp' import { downloadFileViaSftp, @@ -314,7 +315,11 @@ export class SshFilesystemProvider implements IFilesystemProvider { // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. - return (await this.mux.request('fs.listFiles', params, { + // Why streamable: a monorepo listing serializes past the relay's 1 MiB control lane, and the + // lane it demotes to is refused under unrelated producer load. Opting in moves it to the bulk + // lane in chunks; an old relay ignores the flag and answers plainly, which the reader detects + // by the sentinel marker being absent. + return (await requestGitStreamable(this.mux, 'fs.listFiles', params, { signal: options?.signal })) as string[] } diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts index dd0915dafe5..3e6ebf76d74 100644 --- a/src/main/providers/ssh-git-provider-api.test.ts +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -54,6 +54,7 @@ describe('SshGitProvider public API parity', () => { 'worktreeIsClean', 'refreshLocalBaseRefForWorktreeCreate', 'renameCurrentBranch', + 'markRemoteOrcaCreated', 'forceDeletePreservedBranch', 'exec', 'clone', @@ -63,7 +64,7 @@ describe('SshGitProvider public API parity', () => { 'getRemoteCommitUrl' ] as const - expect(methods).toHaveLength(51) + expect(methods).toHaveLength(52) for (const method of methods) { expect(provider[method], method).toBeTypeOf('function') } diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts index 6ddff0a1f68..03722c93a52 100644 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ b/src/main/providers/ssh-git-provider-worktree.test.ts @@ -395,4 +395,38 @@ describe('SshGitProvider', () => { provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') ).rejects.toBe(error) }) + + it('markRemoteOrcaCreated sends the narrow provenance-marker request', async () => { + await provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + expect(mux.request).toHaveBeenCalledWith('git.markRemoteOrcaCreated', { + repoPath: '/home/user/repo', + remoteName: 'pr-contributor-orca' + }) + }) + + it('markRemoteOrcaCreated degrades to a one-time warning for an older relay', async () => { + mux.request.mockRejectedValue(methodNotFound('git.markRemoteOrcaCreated')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).resolves.toBeUndefined() + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).resolves.toBeUndefined() + expect(warnSpy).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + } + }) + + it('markRemoteOrcaCreated rethrows non-method-not-found errors', async () => { + const error = new Error('remote config write failed') + mux.request.mockRejectedValueOnce(error) + + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).rejects.toBe(error) + }) }) diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index d5cf9b5a5e1..8dae1413321 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -2,6 +2,7 @@ import type { GitStatusResult } from '../../shared/git-status-types' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { CapabilityProbeCache } from '../../shared/capability-probe-cache' +import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' @@ -23,6 +24,7 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { private loggedWorktreeIsCleanFallback = false + private loggedMarkRemoteOrcaCreatedFallback = false // Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed. private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< typeof WORKTREE_IS_CLEAN_CAPABILITY @@ -32,11 +34,14 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { repoPath: string, options?: { signal?: AbortSignal } ): Promise { - return (await this.mux.request( + const response = await this.mux.request( 'git.listWorktrees', { repoPath }, { signal: options?.signal } - )) as GitWorktreeInfo[] + ) + // Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are + // normal, so refuse the shape here too — a Git repo always lists its own checkout. + return assertAuthoritativeWorktreeCatalog(response, repoPath) } async addWorktree( @@ -127,6 +132,25 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { }) } + // Why: git.exec blocks config writes outright, so the deferred fork-remote provenance + // marker (#17828) needs its own RPC. Non-essential to push/pull, so an older relay + // that hasn't shipped it yet degrades to no marker rather than failing materialization. + async markRemoteOrcaCreated(repoPath: string, remoteName: string): Promise { + try { + await this.mux.request('git.markRemoteOrcaCreated', { repoPath, remoteName }) + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedMarkRemoteOrcaCreatedFallback) { + this.loggedMarkRemoteOrcaCreatedFallback = true + console.warn( + "[ssh-git] Relay does not implement git.markRemoteOrcaCreated; this remote will lack a git-config provenance marker permanently (reconnecting does not retroactively add it -- only a newer relay deployment does, for remotes added after that). The store's remoteCreated flag remains the fallback ownership signal for cleanup." + ) + } + } + } + async forceDeletePreservedBranch( repoPath: string, branchName: string, diff --git a/src/main/providers/ssh-pty-provider-terminal-repair.test.ts b/src/main/providers/ssh-pty-provider-terminal-repair.test.ts new file mode 100644 index 00000000000..530ce2804d8 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-terminal-repair.test.ts @@ -0,0 +1,155 @@ +// The client half of #17830: a spawn refused for an unloadable node-pty must route into a repair +// instead of printing a paragraph. Covers the seam only — the ledger and the locked rebuild are +// tested in src/main/ssh/ssh-relay-node-pty-repair.test.ts and +// src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshPtyProvider } from './ssh-pty-provider' +import { createMockMux, type MockMultiplexer } from './ssh-pty-provider-mock-multiplexer' +import { + TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + type TerminalUnavailableCause +} from '../../shared/terminal-unavailable-cause' + +const UNAVAILABLE_MESSAGE = + "Remote terminals are unavailable: this host's node-pty binary was built for Node ABI 108." + +function cause(overrides: Partial = {}): TerminalUnavailableCause { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', + repairable: true, + host: { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' + }, + ...overrides + } +} + +/** Shaped exactly as the multiplexer rebuilds a JSON-RPC error response client-side. */ +function relayRejection(data: unknown): Error { + const error = new Error(UNAVAILABLE_MESSAGE) + Object.defineProperty(error, 'code', { value: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE }) + Object.defineProperty(error, 'data', { value: data }) + return error +} + +function spawnCallCount(target: MockMultiplexer): number { + return target.request.mock.calls.filter((call) => call[0] === 'pty.spawn').length +} + +function rejectSpawnOnce(mux: MockMultiplexer, error: Error): void { + mux.request.mockImplementation(async (method: string) => { + if (method === 'pty.spawn') { + throw error + } + return undefined + }) +} + +const SPAWN_OPTS = { cwd: '/repo', cols: 80, rows: 24 } + +let mux: MockMultiplexer +let provider: SshPtyProvider + +beforeEach(() => { + mux = createMockMux() + provider = new SshPtyProvider('conn-1', mux as never) +}) + +describe('terminal-unavailable spawn recovery', () => { + it('routes a repairable cause into recovery and retries once on the repaired provider', async () => { + rejectSpawnOnce(mux, relayRejection(cause())) + const repairedMux = createMockMux() + repairedMux.request.mockResolvedValue({ id: 'pty-1', incarnationId: 'incarnation-1' }) + const repairedProvider = new SshPtyProvider('conn-1', repairedMux as never) + const recover = vi.fn(async () => repairedProvider) + provider.setTerminalUnavailableRecovery(recover) + + const result = await provider.spawn(SPAWN_OPTS) + + expect(result.id).toBe('ssh:conn-1@@pty-1') + expect(recover).toHaveBeenCalledTimes(1) + expect(recover).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'abi_mismatch', status: 'blocked' }) + ) + // Exactly one retry, on the post-repair channel — never a second attempt on the broken one. + expect(spawnCallCount(mux)).toBe(1) + expect(spawnCallCount(repairedMux)).toBe(1) + }) + + it('surfaces the relay message when the retry still fails, and does not recurse into a second repair', async () => { + // The lock-busy shape: the reconnect happened, the rebuild did not, so the relay says the same thing. + rejectSpawnOnce(mux, relayRejection(cause())) + const degradedMux = createMockMux() + const degradedProvider = new SshPtyProvider('conn-1', degradedMux as never) + rejectSpawnOnce(degradedMux, relayRejection(cause())) + const degradedRecover = vi.fn(async () => degradedProvider) + degradedProvider.setTerminalUnavailableRecovery(degradedRecover) + const recover = vi.fn(async () => degradedProvider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + + expect(recover).toHaveBeenCalledTimes(1) + expect(degradedRecover).not.toHaveBeenCalled() + }) + + it('rethrows without recovery when the recovery declines', async () => { + rejectSpawnOnce(mux, relayRejection(cause())) + provider.setTerminalUnavailableRecovery(async () => null) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + }) + + it('never recovers from an unverifiable cause', async () => { + rejectSpawnOnce(mux, relayRejection(cause({ status: 'unverifiable', repairable: true }))) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('never recovers from a toolchain_missing cause', async () => { + rejectSpawnOnce(mux, relayRejection(cause({ reason: 'toolchain_missing', repairable: false }))) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('ignores a malformed cause rather than half-reading it', async () => { + rejectSpawnOnce(mux, relayRejection({ status: 'blocked', repairable: true })) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('leaves an old relay that publishes no cause on today behaviour', async () => { + rejectSpawnOnce(mux, new Error(UNAVAILABLE_MESSAGE)) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow(UNAVAILABLE_MESSAGE) + expect(recover).not.toHaveBeenCalled() + }) + + it('does not swallow an ordinary spawn failure', async () => { + rejectSpawnOnce(mux, new Error('shell not found')) + const recover = vi.fn(async () => provider) + provider.setTerminalUnavailableRecovery(recover) + + await expect(provider.spawn(SPAWN_OPTS)).rejects.toThrow('shell not found') + expect(recover).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index a8e4218ce63..f218cc43eca 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' +import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -38,6 +39,7 @@ export class SshPtyProvider implements IPtyProvider { private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState + private recoverFromTerminalUnavailable: TerminalRepairHook | null = null requestHostRpc: NonNullable = (method, params, options) => this.mux.request(method, params as Record, options) @@ -76,7 +78,24 @@ export class SshPtyProvider implements IPtyProvider { private toAppPtyId = (id: string): string => toAppSshPtyId(this.connectionId, id) + /** Installed by SshRelaySession, which owns the connection, the repair lock and the reconnect. */ + setTerminalUnavailableRecovery(recover: TerminalRepairHook): void { + this.recoverFromTerminalUnavailable = recover + } + + hasLivePtys(): boolean { + return this.livePtyIds.size > 0 + } + async spawn(opts: PtySpawnOptions): Promise { + return await spawnWithTerminalRuntimeRepair({ + attempt: () => this.spawnWithoutTerminalRuntimeRepair(opts), + recover: this.recoverFromTerminalUnavailable, + retry: (provider) => provider.spawnWithoutTerminalRuntimeRepair(opts) + }) + } + + private async spawnWithoutTerminalRuntimeRepair(opts: PtySpawnOptions): Promise { if (opts.agentSessionEnsure && opts.sessionId) { throw new Error('agent_session_claim_unavailable') } diff --git a/src/main/providers/ssh-pty-spawn-repair.ts b/src/main/providers/ssh-pty-spawn-repair.ts new file mode 100644 index 00000000000..5086283f3fb --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-repair.ts @@ -0,0 +1,45 @@ +/** + * The client seam for #17830: a spawn the relay refused because it cannot load node-pty. + * + * Split out of ssh-pty-provider.ts so the provider keeps only the wiring. The repair itself is + * driven by SshRelaySession, which owns the connection, the repair lock and the reconnect. + */ +import { + mayRepairFromCause, + terminalUnavailableCauseFromError, + type TerminalUnavailableCause +} from '../../shared/terminal-unavailable-cause' + +/** Resolves to the provider registered after a successful repair, or null to keep the rejection. */ +export type TerminalRepairHook = ( + cause: TerminalUnavailableCause +) => Promise + +/** + * Run a spawn, and on a proved-repairable terminal-unavailable rejection repair the host and + * retry exactly once on the provider the repair produced. + * + * Re-issuing is safe because a validated cause is the host's own statement that admission was + * refused before any PTY existed, so there is nothing to duplicate. The retry deliberately goes + * through a caller-supplied thunk that does not re-enter this wrapper, so it cannot recurse. + * Gated on `mayRepairFromCause`, never on the peer's `repairable` flag alone. + */ +export async function spawnWithTerminalRuntimeRepair(args: { + attempt: () => Promise + recover: TerminalRepairHook | null + retry: (provider: TProvider) => Promise +}): Promise { + try { + return await args.attempt() + } catch (error) { + const cause = terminalUnavailableCauseFromError(error) + if (!cause || !mayRepairFromCause(cause) || !args.recover) { + throw error + } + const repaired = await args.recover(cause) + if (!repaired) { + throw error + } + return await args.retry(repaired) + } +} diff --git a/src/main/providers/ssh-worktree-catalog-authority.test.ts b/src/main/providers/ssh-worktree-catalog-authority.test.ts new file mode 100644 index 00000000000..3f11c87dc0f --- /dev/null +++ b/src/main/providers/ssh-worktree-catalog-authority.test.ts @@ -0,0 +1,141 @@ +/** + * Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative + * empty catalog. Covers the whole client-side chain — provider response guard, the repo-level + * listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal + * teardown (`teardownMissingWorktreeTerminalsBestEffort`). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' +import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability' +import { listRepoWorktrees } from '../repo-worktrees' +import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence/loading-store/store' + +const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() })) + +vi.mock('./ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + requireSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 1 +})) + +const CONNECTION_ID = 'conn-1' +const REPO_PATH = '/home/user/repo' +const WORKTREE_PATH = '/home/user/feature' + +const repo: Repo = { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + connectionId: CONNECTION_ID +} as Repo + +const worktreeId = `${repo.id}::${WORKTREE_PATH}` + +function createStore(): Store { + const meta: Record = { + [worktreeId]: { instanceId: 'instance-1' } + } + return { + getRepos: () => [repo], + getRepo: () => repo, + getAllWorktreeMeta: () => meta, + getWorktreeMeta: (id: string) => meta[id], + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getProjectHostSetups: () => [], + getSettings: () => ({}) + } as unknown as Store +} + +describe('SSH worktree catalog authority (#14004)', () => { + let mux: MockMultiplexer + let provider: SshGitProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshGitProvider(CONNECTION_ID, mux as never) + getSshGitProviderMock.mockReset() + getSshGitProviderMock.mockReturnValue(provider) + }) + + it('refuses an empty relay response instead of publishing an empty catalog', async () => { + // An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state. + mux.request.mockResolvedValue([]) + + await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( + isWorktreeCatalogUnavailableError + ) + }) + + it('refuses a malformed relay response', async () => { + mux.request.mockResolvedValue(undefined) + + await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( + isWorktreeCatalogUnavailableError + ) + }) + + it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => { + getSshGitProviderMock.mockReturnValue(undefined) + + await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError) + }) + + it('does not authorize missing-worktree teardown when the relay listing fails', async () => { + mux.request.mockRejectedValue(new Error('relay request failed')) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + // The persisted workspace survives the failed scan, so the renderer has nothing to reconcile away. + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toContain(worktreeId) + }) + + it('does not authorize missing-worktree teardown when the relay answers with an empty list', async () => { + mux.request.mockResolvedValue([]) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toContain(worktreeId) + }) + + it('republishes an authoritative catalog once the relay answers again', async () => { + mux.request.mockResolvedValue([ + { path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: WORKTREE_PATH, + head: 'def456', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: true, source: 'git' }) + }) +}) diff --git a/src/main/providers/windows-foreground-process-inspection-cost.test.ts b/src/main/providers/windows-foreground-process-inspection-cost.test.ts new file mode 100644 index 00000000000..f08d1b690bb --- /dev/null +++ b/src/main/providers/windows-foreground-process-inspection-cost.test.ts @@ -0,0 +1,156 @@ +// Regression guard on the per-inspection cost of Windows agent foreground +// inspection — the Windows analogue of the POSIX index memo (#6288). +// +// The shared TTL cache already collapses N panes into one Toolhelp32 snapshot +// (windows-agent-foreground-process-scan-volume.test.ts). What it never +// collapsed is the work each pane does ON that snapshot: a full +// `native.map(toProcessRow)` projection, a `childrenByPpid` Map rebuilt from +// scratch, and two linear scans. This file counts that work at a realistic +// table size and pane count, and pins the flag set the snapshot asks for. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { + queryWindowsPaneProcessInventory, + resetWindowsProcessRowsSnapshotForTests +} from './windows-foreground-process-rows' + +// 1050 processes is the host measured in windows-process-enumeration.md; 11 +// panes is the fan-out the shared snapshot exists to serve. +const TABLE_SIZE = 1050 +const PANE_COUNT = 11 + +const SELF_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' } + +const shellPid = (pane: number): number => 10_000 + pane * 10 +const agentPid = (pane: number): number => shellPid(pane) + 1 +/** A row every pane can look up, so distinct results == distinct projections. */ +const PROBE_PID = 900_000 + TABLE_SIZE - 1 + +/** One shell + one agent child per pane, padded out to a real table size. */ +function buildNativeTable(): { pid: number; ppid: number; name: string; commandLine: string }[] { + const rows = [SELF_ROW] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + rows.push({ pid: shellPid(pane), ppid: 4, name: 'cmd.exe', commandLine: 'cmd.exe' }) + rows.push({ + pid: agentPid(pane), + ppid: shellPid(pane), + name: 'node.exe', + commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js' + }) + } + for (let filler = rows.length; filler < TABLE_SIZE; filler += 1) { + rows.push({ pid: 900_000 + filler, ppid: 4, name: 'svchost.exe', commandLine: 'svchost.exe' }) + } + return rows +} + +const NATIVE_TABLE = buildNativeTable() + +/** + * Count `Map.prototype.set` calls — the primitive both the old per-call + * `childrenByPpid` rebuild and the shared index build are made of. Patched for + * one awaited region and restored in `finally`, so nothing else observes it. + */ +async function countMapInsertions(run: () => Promise): Promise { + const original = Map.prototype.set + let insertions = 0 + Map.prototype.set = function patched(this: Map, key: unknown, value: unknown) { + insertions += 1 + return original.call(this, key, value) + } as typeof Map.prototype.set + try { + await run() + } finally { + Map.prototype.set = original + } + return insertions +} + +describe('windows foreground inspection cost per pane', () => { + const getAllProcesses = vi.fn() + let platform: PropertyDescriptor | undefined + let flagsSeen: number[] = [] + + beforeEach(() => { + flagsSeen = [] + getAllProcesses.mockReset() + getAllProcesses.mockImplementation((cb: (rows: unknown) => void, flags: number) => { + flagsSeen.push(flags) + cb(NATIVE_TABLE) + }) + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses + })) + resetWindowsProcessRowsSnapshotForTests() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + __setWindowsProcessTreeLoaderForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + async function sweepPanes(): Promise<(number | undefined)[]> { + const resolved: (number | undefined)[] = [] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), { + anchorPid: agentPid(pane) + }) + expect(inventory?.candidates).toHaveLength(1) + resolved.push(inventory?.candidates[0]?.pid) + } + return resolved + } + + it('never sets the Memory flag on the snapshot', async () => { + await queryWindowsPaneProcessInventory(shellPid(0)) + expect(flagsSeen).toHaveLength(1) + // Memory is bit 0, and it costs the addon a second OpenProcess per process + // carrying PROCESS_VM_READ (process.cc `GetProcessMemoryUsage`). + expect(flagsSeen[0]! & 1).toBe(0) + // CommandLine (2) | CreationTime (4). + expect(flagsSeen[0]).toBe(6) + }) + + it('projects the shared snapshot once for the whole pane fan-out', async () => { + const probeRows: unknown[] = [] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), { + anchorPid: PROBE_PID + }) + probeRows.push(inventory?.anchorRow) + } + expect(probeRows.filter(Boolean)).toHaveLength(PANE_COUNT) + // One projection produced every pane's row object. Pre-fix each pane ran + // its own `native.map(toProcessRow)` over all 1050 rows, so this set held + // PANE_COUNT distinct objects and the sweep allocated PANE_COUNT * 1050. + expect(new Set(probeRows).size).toBe(1) + }) + + it('indexes the shared snapshot once for the whole pane fan-out', async () => { + // Prime the TTL cache and the index so the snapshot read is not in the count. + await queryWindowsPaneProcessInventory(shellPid(0), { anchorPid: agentPid(0) }) + + const insertions = await countMapInsertions(async () => { + await sweepPanes() + }) + + // Pre-fix every pane rebuilt a whole-table `childrenByPpid`, so this was + // >= PANE_COUNT * (rows with a distinct ppid). One shared index makes the + // whole sweep cost no table-sized Map build at all. + expect(insertions).toBeLessThan(TABLE_SIZE) + }) + + it('resolves the same foreground child for every pane as an unshared scan would', async () => { + const resolved = await sweepPanes() + expect(resolved).toEqual(Array.from({ length: PANE_COUNT }, (_, pane) => agentPid(pane))) + }) +}) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index f5a74bd0fe2..5f462649e6c 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -1,3 +1,4 @@ +import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' import { readWindowsProcessTable, readWindowsProcessTableFresh, @@ -25,15 +26,40 @@ function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow { } } +/** + * One projection per snapshot identity, mirroring `getProcessTableIndex`. + * + * The TTL cache already gives every pane the same native rows array; without + * this each of them still rebuilt ~1050 row objects, which also handed + * `getProcessTableIndex` a new array each time and defeated its memo by + * construction. Keyed weakly, so a projection dies with its snapshot. Rows are + * shared, never mutated: descendants are copied with their depth, and + * `anchorRow` is read-only to every caller. + */ +const projectedRows = new WeakMap() + +function projectProcessRows(native: readonly NativeWindowsProcessRow[]): WindowsProcessRow[] { + const cached = projectedRows.get(native) + if (cached) { + return cached + } + const rows = native.map(toProcessRow) + projectedRows.set(native, rows) + return rows +} + /** * Rows from a scan that starts after this call. * * PID-identity checks in teardown must not reuse a cached row — it can predate * the very recycle it is meant to detect. Rejects when the table is unreadable, * so "unavailable" stays distinguishable from "nothing is running". + * + * `readonly` because the projection is shared with every other reader of the + * same snapshot. */ -export async function queryWindowsProcessRowsFresh(): Promise { - return (await readWindowsProcessTableFresh()).map(toProcessRow) +export async function queryWindowsProcessRowsFresh(): Promise { + return projectProcessRows(await readWindowsProcessTableFresh()) } export async function queryWindowsProcessDescendants( @@ -63,21 +89,22 @@ export async function queryWindowsPaneProcessInventory( options.fresh === true ? await readWindowsProcessTableFresh() : await readWindowsProcessTable() - rows = native.map(toProcessRow) + rows = projectProcessRows(native) } catch { return null } + // One index per snapshot, shared by every pane inspecting inside the TTL + // window: `byPid` answers both lookups that used to be linear scans, and + // `childrenByPpid` replaces a per-call Map rebuild over the whole table. + const index = getProcessTableIndex(rows) // Why: a snapshot that omitted the PTY root may be stale or permission- // filtered; only an observed root can authoritatively have no descendants. - if (!rows.some((row) => row.pid === rootPid)) { + if (!index.byPid.has(rootPid)) { return null } return { - candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth), - anchorRow: - options.anchorPid !== undefined - ? (rows.find((row) => row.pid === options.anchorPid) ?? null) - : null + candidates: collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth), + anchorRow: options.anchorPid !== undefined ? (index.byPid.get(options.anchorPid) ?? null) : null } } @@ -85,26 +112,3 @@ export async function queryWindowsPaneProcessInventory( export function resetWindowsProcessRowsSnapshotForTests(): void { resetWindowsProcessTableForTests() } - -function collectDescendants( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (Row & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 4f984559a63..70fd22a06ad 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -14,7 +14,8 @@ import { } from '../powershell-osc133-bootstrap' import { quoteStartupArg } from '../../shared/tui-agent-startup-shell' -const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 +/** cmd.exe's own documented ceiling; callers that go through sshd budget below it. */ +export const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' @@ -202,6 +203,7 @@ export function resolveWindowsShellLaunchArgs( const powerShellCommand = getPowerShellEncodedCommand(nativeCwd, startupCommand) // Why: foreground-process status on Windows depends on OSC 133 C/D, and // PowerShell needs a prompt/readline bootstrap after profiles finish. + // Why base64 and not -Command: see powershell-osc133-bootstrap.ts (MDE review). return { shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand], ...(powerShellCommand.startupCommandDeliveredInShellArgs diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index c5e756b9310..b7c6e16f91a 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -4,6 +4,7 @@ import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/wor import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' type LocalRepoWorktreeListOptions = { wslDistro?: string @@ -42,10 +43,15 @@ export async function listRepoWorktrees( } if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: runtime worktree resolution can run before SSH providers have - // reattached during startup. Return empty instead of falling back to - // local git against a server path. - return provider ? await provider.listWorktrees(repo.path) : [] + // Why: runtime worktree resolution can run before SSH providers have reattached during startup. + // Never fall back to local git against a server path, and never report the unreachable host as an + // empty catalog (#14004) — callers treat a resolved listing as authoritative. + if (!provider) { + throw new WorktreeCatalogUnavailableError( + `Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.` + ) + } + return await provider.listWorktrees(repo.path) } return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) diff --git a/src/main/runtime/decorative-title-fact-emission.test.ts b/src/main/runtime/decorative-title-fact-emission.test.ts new file mode 100644 index 00000000000..d61bb86f8b9 --- /dev/null +++ b/src/main/runtime/decorative-title-fact-emission.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + DECORATIVE_TITLE_FACT_HEARTBEAT_MS, + shouldEmitTitleFactForFrame +} from './decorative-title-fact-emission' + +const base = { + decorativeOnly: true, + staleWorkingTitleClear: false, + lastEmittedAtMs: 1_000, + nowMs: 1_000 +} + +describe('shouldEmitTitleFactForFrame', () => { + it('always emits a frame that is not a decorative repeat', () => { + expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true) + }) + + it('emits the first frame of a pane', () => { + expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true) + }) + + it('suppresses a decorative repeat inside the heartbeat window', () => { + expect( + shouldEmitTitleFactForFrame({ + ...base, + nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 + }) + ).toBe(false) + }) + + it('lets a decorative repeat through once the heartbeat window elapses', () => { + expect( + shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS }) + ).toBe(true) + }) + + it('never throttles a timer-synthesized stale-working clear', () => { + // Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for. + expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true) + }) + + it('emits after a backwards clock step instead of parking until it catches up', () => { + expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true) + }) + + it('keeps at least three frames inside the renderer hook-done quiet window', () => { + // Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done` + // scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion. + expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500) + }) +}) diff --git a/src/main/runtime/decorative-title-fact-emission.ts b/src/main/runtime/decorative-title-fact-emission.ts new file mode 100644 index 00000000000..d8248dc12c5 --- /dev/null +++ b/src/main/runtime/decorative-title-fact-emission.ts @@ -0,0 +1,38 @@ +/** + * Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own + * synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer + * as its own `pty:sideEffect` message. Both renderer store writes already discard those frames + * via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost. + * + * Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an + * arriving *working* title as "still working" and cancels a scheduled hook-`done` completion + * inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted + * mid-turn is stopped from minting a completion notification, and the frames that carry it are + * decorative repeats. 500ms keeps 3 frames inside that window. + */ +export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500 + +export type DecorativeTitleFactEmissionInput = { + /** The frame's decorative gate key matches the previous frame's. */ + decorativeOnly: boolean + /** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */ + staleWorkingTitleClear: boolean + lastEmittedAtMs: number | null + nowMs: number +} + +export function shouldEmitTitleFactForFrame({ + decorativeOnly, + staleWorkingTitleClear, + lastEmittedAtMs, + nowMs +}: DecorativeTitleFactEmissionInput): boolean { + if (!decorativeOnly || staleWorkingTitleClear) { + return true + } + if (lastEmittedAtMs === null) { + return true + } + // A backwards clock step must not park the heartbeat until it catches up. + return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS +} diff --git a/src/main/runtime/folder-workspace-pty-teardown.ts b/src/main/runtime/folder-workspace-pty-teardown.ts new file mode 100644 index 00000000000..f5a5b7df4b3 --- /dev/null +++ b/src/main/runtime/folder-workspace-pty-teardown.ts @@ -0,0 +1,38 @@ +import { killAllProcessesForWorktree } from './worktree-teardown' +import type { IPtyProvider } from '../providers/types' +import type { OrcaRuntimeService } from './orca-runtime' + +export type FolderWorkspacePtyTeardownDeps = { + runtime: OrcaRuntimeService + getSshProvider: ((connectionId: string) => IPtyProvider | undefined) | null + getLocalProvider: () => IPtyProvider | null + onPtyStopped: ((ptyId: string) => void) | null +} + +/** + * Best-effort PTY sweep for a folder workspace being removed. Never throws: + * a stuck or unreachable host must not block forgetting the workspace. + */ +export async function teardownFolderWorkspacePtys( + deps: FolderWorkspacePtyTeardownDeps, + worktreeId: string, + connectionId: string | null +): Promise { + const sshPtyProvider = connectionId ? deps.getSshProvider?.(connectionId) : undefined + const ptyProvider = sshPtyProvider ?? deps.getLocalProvider() + if (!ptyProvider) { + return + } + await killAllProcessesForWorktree(worktreeId, { + runtime: deps.runtime, + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), + localProvider: ptyProvider, + onPtyStopped: deps.onPtyStopped ?? undefined, + ...(connectionId + ? { includeProviderInventory: Boolean(sshPtyProvider), includeLocalRegistry: false } + : {}) + }).catch((error) => { + console.warn(`[worktree-teardown] failed for ${worktreeId}:`, error) + }) +} diff --git a/src/main/runtime/graph-sync-deletion-fence.test.ts b/src/main/runtime/graph-sync-deletion-fence.test.ts new file mode 100644 index 00000000000..499607ea5bb --- /dev/null +++ b/src/main/runtime/graph-sync-deletion-fence.test.ts @@ -0,0 +1,200 @@ +/** + * Deletion fence: a renderer snapshot that raced a worktree delete must not + * resurrect the removed occupant's browser/terminal rows in a same-id + * recreation, while the genuine successor is accepted promptly. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' + +const WT = 'repo-1::/tmp/worktree-a' + +const storeBase = { + getRepo: () => ({ + id: 'repo-1', + path: '/tmp/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }), + getRepos: () => [storeBase.getRepo()], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getGitHubCache: () => ({ pr: {}, issue: {} }), + setWorktreeMeta: () => undefined as never, + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) +} + +function makeRendererSnapshot(args: { + version: number + epoch?: string +}): RuntimeMobileSessionTabsSnapshot { + return { + worktree: WT, + publicationEpoch: args.epoch ?? 'renderer:test-epoch', + snapshotVersion: args.version, + activeGroupId: 'group-1', + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + parentTabId: 'tab-1', + leafId: 'leaf-1', + title: 'Terminal 1', + isActive: true + } + ] + } +} + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map +} + +describe('graph-sync deletion fence', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + type FenceInternals = RuntimeInternals & { + removedMobileSessionWorktreeIds: Map + removeWorktreeMetadataAndHistory: (store: unknown, worktreeId: string) => void + rendererGeneration: string | null + } + + function createFencedRuntime() { + let meta: { instanceId: string; hostId?: string } | undefined = { instanceId: 'old-instance' } + const store = { + ...storeBase, + getWorktreeMeta: () => meta, + removeWorktreeMeta: () => { + meta = undefined + } + } + const runtime = new OrcaRuntimeService(store as never) + const internals = runtime as unknown as FenceInternals + const events: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + const sync = ( + mobileSessionTabs: RuntimeMobileSessionTabsSnapshot[], + extra: { rendererGeneration?: string; unchanged?: string[] } = {} + ) => + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + ...(extra.rendererGeneration ? { rendererGeneration: extra.rendererGeneration } : {}), + mobileSessionTabs, + ...(extra.unchanged ? { unchangedMobileSessionWorktrees: extra.unchanged } : {}) + } as never) + const recreate = (instanceId: string): void => { + meta = { instanceId } + } + const remove = (): void => internals.removeWorktreeMetadataAndHistory(store, WT) + return { runtime, internals, events, sync, recreate, remove } + } + + it("rejects the deleted occupant's late snapshot after same-id recreation", () => { + const { internals, events, sync, recreate, remove } = createFencedRuntime() + sync([{ ...makeRendererSnapshot({ version: 1 }), worktreeInstanceId: 'old-instance' }]) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(true) + events.length = 0 + + remove() + expect(events).toEqual([expect.objectContaining({ worktree: WT, removed: true })]) + events.length = 0 + recreate('new-instance') + + sync([{ ...makeRendererSnapshot({ version: 2 }), worktreeInstanceId: 'old-instance' }]) + vi.advanceTimersByTime(60) + + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(false) + expect(events).toHaveLength(0) + }) + + it("accepts the recreated occupant's snapshot and clears the fence", () => { + const { internals, events, sync, recreate, remove } = createFencedRuntime() + remove() + events.length = 0 + recreate('new-instance') + + sync([{ ...makeRendererSnapshot({ version: 3 }), worktreeInstanceId: 'new-instance' }]) + vi.advanceTimersByTime(60) + + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(true) + expect(events).toEqual([expect.objectContaining({ worktree: WT, snapshotVersion: 3 })]) + expect(internals.removedMobileSessionWorktreeIds.has(WT)).toBe(false) + }) + + it('rejects a snapshot while the removed id has no successor metadata', () => { + const { internals, events, sync, remove } = createFencedRuntime() + remove() + events.length = 0 + + sync([{ ...makeRendererSnapshot({ version: 2 }), worktreeInstanceId: 'new-instance' }]) + vi.advanceTimersByTime(60) + + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(false) + expect(events).toHaveLength(0) + }) + + it('fences identity-less frames from the generation that published the deleted occupant', () => { + const { internals, events, sync, recreate, remove } = createFencedRuntime() + sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:gen-1' })], { + rendererGeneration: 'renderer:gen-1' + }) + vi.advanceTimersByTime(60) + remove() + recreate('new-instance') + events.length = 0 + + sync([makeRendererSnapshot({ version: 2, epoch: 'renderer:gen-1' })], { + rendererGeneration: 'renderer:gen-1' + }) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(false) + expect(events).toHaveLength(0) + + // A reloaded renderer publishes a fresh generation; the resync-path throw + // on a superseded generation needs the graph to leave 'ready' first. + internals.rendererGeneration = null + sync([makeRendererSnapshot({ version: 1, epoch: 'renderer:gen-2' })], { + rendererGeneration: 'renderer:gen-2' + }) + vi.advanceTimersByTime(60) + expect(internals.mobileSessionTabsByWorktree.has(WT)).toBe(true) + }) + + it('does not request a resync for a fenced frame the renderer still lists as unchanged', () => { + const { sync, recreate, remove } = createFencedRuntime() + remove() + recreate('new-instance') + + const first = sync([ + { ...makeRendererSnapshot({ version: 2 }), worktreeInstanceId: 'old-instance' } + ]) + const second = sync([], { unchanged: [WT] }) + + expect(first.mobileSessionResyncWorktrees ?? []).toEqual([]) + expect(second.mobileSessionResyncWorktrees ?? []).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 9199e46783a..ac64dfaa6b7 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -66,6 +66,50 @@ function createRuntime(provider?: { return runtime } +// Why: an SSH-backed workspace whose spawn response was lost — the leak in #17929. +function installRemoteReclaimHarness( + runtime: OrcaRuntimeService, + listProcesses: ReturnType +): void { + const handleByPtyId = new Map() + Object.assign(runtime, { + ptyController: { listProcesses }, + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: 'worktree-1', + path: '/remote/worktree-1', + connectionId: 'ssh-1' + })), + executionOwnerSupportsAgentSessionOperation: vi.fn(async () => true), + markWorkspaceTrustedForAgent: vi.fn(async () => {}), + adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => { + handleByPtyId.set(ptyId, handle) + }), + recordPtyWorktree: vi.fn((ptyId: string, worktreeId: string, state: { title?: string }) => ({ + ptyId, + worktreeId, + title: state.title ?? null + })), + issuePtyHandle: vi.fn((pty: { ptyId: string }) => handleByPtyId.get(pty.ptyId)) + }) +} + +async function fenceRemoteAgentSessionSpawn(runtime: OrcaRuntimeService) { + const failure = Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) + const createTerminal = vi + .spyOn(runtime, 'createTerminal') + .mockImplementation(async (_worktree, opts) => { + opts?.onPtySpawnCommitted?.() + throw failure + }) + const id = operationId() + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + return { createTerminal, failure, id } +} + describe('agent-session create operation ledger', () => { it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => { const provider = { @@ -291,6 +335,81 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).toHaveBeenCalledOnce() }) + it('reclaims a fenced remote spawn the host is still holding', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => [] as never[]) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + const orphanHandle = createTerminal.mock.calls[0]?.[1]?.preAllocatedHandle as string + listProcesses.mockResolvedValue([ + { + id: 'ssh-1:pty2:e:1', + cwd: '/remote/worktree-1', + title: 'codex', + worktreeId: 'worktree-1', + terminalHandle: orphanHandle + } + ] as never) + + await expect( + runtime.createAgentSession(request(id), { clientId: 'device-a' }) + ).resolves.toMatchObject({ + disposition: 'replayed', + terminal: { handle: orphanHandle, ptyId: 'ssh-1:pty2:e:1', worktreeId: 'worktree-1' } + }) + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + expect(createTerminal).toHaveBeenCalledOnce() + expect(failure.message).toBe('execution_owner_unavailable') + }) + + it('replays the fenced failure when host inventory proves the spawn is gone', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => [] as never[]) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('replays the fenced failure when the remote host cannot answer', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => { + throw new Error('relay offline') + }) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('refuses to adopt a same-handle PTY that belongs to another workspace', async () => { + const runtime = createRuntime() + const listProcesses = vi.fn(async () => [] as never[]) + installRemoteReclaimHarness(runtime, listProcesses) + const { createTerminal, id, failure } = await fenceRemoteAgentSessionSpawn(runtime) + listProcesses.mockResolvedValue([ + { + id: 'ssh-1:pty2:e:9', + cwd: '/remote/worktree-2', + title: 'codex', + worktreeId: 'worktree-2', + terminalHandle: createTerminal.mock.calls[0]?.[1]?.preAllocatedHandle + } + ] as never) + + await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow( + failure.message + ) + expect(createTerminal).toHaveBeenCalledOnce() + }) + it('retains a replay fence when the provider reports an unknown spawn outcome', async () => { const runtime = createRuntime() const failure = Object.assign(new Error('cleanup could not prove exit'), { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 03d187b717e..db2b71a0adc 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -24,6 +24,10 @@ import { } from '../../shared/tui-agent-launch-defaults' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' +import type { + AgentSessionCreateOperation, + AgentSessionCreateReclaimIdentity +} from './runtime-terminal-contracts' import { deterministicAgentSessionUuid, isAgentSessionOperationOutcomeUnknown @@ -72,7 +76,16 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe if (existing.fingerprint !== requestFingerprint) { throw new Error('agent_session_operation_conflict') } - const replayed = await existing.promise + let replayed: RuntimeCreateAgentSessionResult + try { + replayed = await existing.promise + } catch (error) { + const reclaimed = await this.reclaimFencedAgentSessionSpawn(existing.reclaim.identity) + if (!reclaimed) { + throw error + } + return { terminal: reclaimed, disposition: 'replayed' } + } return { ...replayed, disposition: 'replayed' } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { @@ -96,6 +109,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe throw new Error('agent_session_operation_capacity') } let retainReplayFence = false + const reclaim: AgentSessionCreateOperation['reclaim'] = {} const operation = (async (): Promise => { // Why: reserve the client operation before any async preflight so concurrent retries cannot // both observe an empty ledger and reach the execution owner independently. @@ -187,6 +201,13 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe const operationLeafId = request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`) const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}` + // Why: recorded before dispatch — this handle is exported into the PTY as + // ORCA_TERMINAL_HANDLE, so it is the only name a lost spawn can be re-found by. + reclaim.identity = { + worktreeId: workspace.id, + connectionId: workspace.connectionId ?? null, + terminalHandle: operationHandle + } try { terminal = await this.createTerminal(`id:${workspace.id}`, { command: startup.launchCommand, @@ -216,7 +237,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe })() this.agentSessionCreateOperations.set(operationKey, { fingerprint: requestFingerprint, - promise: operation + promise: operation, + reclaim }) const expireOperation = (): void => { const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS @@ -245,4 +267,25 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe throw error } } + + // Why: the host may still hold the PTY this operation launched. Adoption-only — + // this never spawns and never kills, so an unreachable or silent host just replays + // the original failure instead of authorising anything. + private async reclaimFencedAgentSessionSpawn( + identity: AgentSessionCreateReclaimIdentity | undefined + ): Promise { + if (!identity) { + return null + } + try { + return await this.reconcileRemoteTerminalCreate( + identity.worktreeId, + identity.terminalHandle, + identity.connectionId + ) + } catch { + // Unverifiable or ambiguous inventory is never evidence the PTY exited. + return null + } + } } diff --git a/src/main/runtime/orca-runtime-fence-automation-owner.ts b/src/main/runtime/orca-runtime-fence-automation-owner.ts index 626d697716a..a90730c7886 100644 --- a/src/main/runtime/orca-runtime-fence-automation-owner.ts +++ b/src/main/runtime/orca-runtime-fence-automation-owner.ts @@ -53,6 +53,23 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg }) } + listAutomationRunsPage( + automationId?: string, + expectedOwner?: AutomationOwnerPrecondition, + limit?: number, + cursor?: string + ) { + if (expectedOwner && !automationId) { + throw new Error('An expected owner requires an automation id.') + } + return this.automation.withExternalProbePriority(() => { + if (automationId) { + this.fenceAutomationOwner(automationId, expectedOwner, 'read') + } + return this.automation.listRunsPage(automationId, limit, cursor) + }) + } + showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation { const automation = this.automation.show(id) this.fenceAutomationOwner(id, expectedOwner, 'read') diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index c44daefacfd..c195ee5dbd8 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -97,6 +97,16 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC linkedWorkItem: meta.linkedWorkItem } : null + }, + // Why (#17828 review follow-up): RuntimeGitSyncCommands materializes with no store to + // avoid unrelated side effects; this is its only way back into the persisted + // `pushTarget.remoteCreated` flag that #17842's orphan sweep relies on. + persistMaterializedPushTarget: (worktreeId, pushTarget) => { + const store = this.store + if (!store?.setWorktreeMeta) { + return + } + store.setWorktreeMeta(worktreeId, { pushTarget }) } }) diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 37fac094067..32dd82fd48c 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -112,13 +112,25 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (!ptyId || !trackedPty || !this.ptyController) { return false } - const agent = recognizeAgentProcess( - await this.ptyController.getForegroundProcess(ptyId) - )?.agent + let foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) + let agent = recognizeAgentProcess(foregroundProcess)?.agent + // Why: the cached foreground name can be an executable basename nothing recognizes + // (macOS p_comm reports the native Claude installer as `2.1.258`), and treating that + // as "no agent" silently downgrades the prompt to unframed chunks, which Claude's + // composer truncates. A fresh process-table scan reads the real command line. + if (agent === undefined && this.ptyController.confirmForegroundProcess) { + foregroundProcess = await this.ptyController.confirmForegroundProcess(ptyId) + agent = recognizeAgentProcess(foregroundProcess)?.agent + } if (agent !== 'claude' && agent !== 'codex') { return false } - if (!(await this.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }))) { + if ( + !(await this.isTerminalRunningAgent(handle, { + retryForegroundWrappers: false, + foregroundProcess + })) + ) { return false } trackedPty.foregroundAgent = agent diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 38bae372176..3ece144bc24 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact' import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' +import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime const tracker = createTerminalTitleTracker( { onTitle: (normalizedTitle, rawTitle, meta) => { - this.recordTerminalSideEffectFact(ptyId, { - kind: 'title', - normalizedTitle, - rawTitle, - ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) - }) - const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } + // Why: the same gate the mobile fan-out below already uses, applied one hop earlier — + // a spinner frame the renderer store discards should not cost a pty:sideEffect message + // at all. See decorative-title-fact-emission.ts for why repeats still heartbeat. + const nowMs = Date.now() + if ( + shouldEmitTitleFactForFrame({ + decorativeOnly, + staleWorkingTitleClear: meta?.staleWorkingTitleClear === true, + lastEmittedAtMs: live?.lastTitleFactAtMs ?? null, + nowMs + }) + ) { + if (live) { + live.lastTitleFactAtMs = nowMs + } + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + } + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const tracksReplicatedStatus = live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null @@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime tracker, applyingChunk: false, lastMobileTitleGateKey: null, + lastTitleFactAtMs: null, chunkTouchedSessionTabs: false, pendingFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index a78acbad8f5..43853f0f9c0 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -38,6 +38,7 @@ import { RuntimeRepositoryForkBackfill } from './runtime-repository-fork-backfil import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller' import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' +import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers { protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => @@ -203,7 +204,24 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly projectGroups = new RuntimeProjectGroupController({ getStore: () => this.store, resolveRepo: (selector) => this.resolveRepoSelector(selector), - notifyReposChanged: () => this.notifyReposChanged() + notifyReposChanged: () => this.notifyReposChanged(), + resolveFolderConnectionId: (workspace) => this.resolveFolderWorkspaceConnectionId(workspace), + teardownFolderWorkspacePtys: (worktreeId, connectionId) => + teardownFolderWorkspacePtys( + { + runtime: this, + getSshProvider: this.getSshProviderFn, + getLocalProvider: () => this.getLocalProvider(), + onPtyStopped: this.onPtyStopped + }, + worktreeId, + connectionId + ), + cleanupRemovedFolderWorkspaceState: (worktreeId) => { + if (this.store) { + this.removeWorktreeMetadataAndHistory(this.store, worktreeId) + } + } }) protected readonly nestedRepoImport = new RuntimeNestedRepoImport({ diff --git a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts index 697950f229a..d49dc410cd5 100644 --- a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts @@ -6,6 +6,7 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { isFolderRepo } from '../../shared/repo-kind' import { getRuntimeFolderWorkspaceRootId } from './runtime-folder-workspace' import { killAllProcessesForWorktree } from './worktree-teardown' +import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' export async function removeOrphanOrFolderWorktree({ runtime, @@ -94,19 +95,16 @@ export async function removeOrphanOrFolderWorktree({ const folderSshPtyProvider = folderConnectionId ? runtime.getSshProviderFn?.(folderConnectionId) : undefined - const folderPtyProvider = folderSshPtyProvider ?? runtime.getLocalProvider() - if (folderPtyProvider) { - await killAllProcessesForWorktree(removalTarget.id, { + await teardownFolderWorkspacePtys( + { runtime, - resolvedWorktreeId: removalTarget.id, - ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), - localProvider: folderPtyProvider, - onPtyStopped: runtime.onPtyStopped ?? undefined, - ...(folderConnectionId - ? { includeProviderInventory: Boolean(folderSshPtyProvider), includeLocalRegistry: false } - : {}) - }).catch((err) => console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)) - } + getSshProvider: runtime.getSshProviderFn, + getLocalProvider: () => runtime.getLocalProvider(), + onPtyStopped: runtime.onPtyStopped + }, + removalTarget.id, + folderConnectionId + ) await deleteRemoteWorktreeHistory(folderSshPtyProvider, removalTarget.id) runtime.removeWorktreeMetadataAndHistory(store, removalTarget.id, removalHostId) runtime.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index 3ae942280eb..87411ad55d7 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -23,6 +23,7 @@ import { homedir } from 'node:os' import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' +import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { @@ -124,6 +125,14 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) ?? null + triggerTerminalSpawnPushTargetMaterialization( + worktree.path, + worktree.pushTarget, + repo, + this.store, + worktree.repoId, + worktree.id + ) return { scope: { id: worktree.id, diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index f4790b1a697..58fd6231fc7 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -5,6 +5,7 @@ import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection' import { resolveRuntimeWorktreeRemovalTarget } from './runtime-worktree-removal-target' import type { RuntimeStore } from './runtime-store-contract' import { splitWorktreeId } from '../../shared/worktree/id' +import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../worktree-removal-repo-owner' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' @@ -52,15 +53,36 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ((persistedHostId && persistedHostId !== hostId) || (repoId && hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId))) ) + const acceptedRendererSnapshot = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) + const storedSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (hostId) { store.removeWorktreeMeta(worktreeId, hostId) } else { store.removeWorktreeMeta(worktreeId) } if (!preservesSameIdOwner) { + // A paired PTY can outlive the delete acknowledgement; it must not be + // rescued into a newly-created occupant of the same path-derived ID. + for (const ptyId of this.pairedRendererSessionOwnedPtyIds) { + const ptyWorktreeId = this.ptysById.get(ptyId)?.worktreeId + if (ptyWorktreeId && runtimeWorktreeIdsEqual(ptyWorktreeId, worktreeId)) { + this.pairedRendererSessionOwnedPtyIds.delete(ptyId) + } + } + const removedPublicationEpoch = + acceptedRendererSnapshot?.publicationEpoch ?? + storedSnapshot?.publicationEpoch ?? + this.rendererGeneration ?? + undefined + this.removedMobileSessionWorktreeIds.set( + worktreeId, + removedPublicationEpoch ? { removedPublicationEpoch } : {} + ) this.mobileSessionTabsByWorktree.delete(worktreeId) this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) + this.cancelScheduledMobileSessionTabsChanged(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 2d56a587c08..da55f2229b6 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -126,6 +126,20 @@ export class OrcaRuntimeWithRuntimeId { } >() + // Why: worktree ids are path-derived and get recreated, so a renderer frame + // that raced the delete must be rejected by the removed occupant's identity. + // Entries are cleared once a snapshot carrying the successor's instanceId + // is accepted; identity-less frames are fenced by renderer generation. + protected readonly removedMobileSessionWorktreeIds = new Map< + string, + { + removedPublicationEpoch?: string + // Why: a rejected frame is still "published" on the renderer side, so a + // later unchanged-list mention must not spiral into resync requests. + rejectedPublication?: boolean + } + >() + protected clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the diff --git a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts index d8c00eda95d..4d54a322f3e 100644 --- a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts +++ b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts @@ -11,7 +11,8 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr protected syncMobileSessionTabs( snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, unchangedWorktreeIds?: string[], - resyncWorktreeIds = new Set() + resyncWorktreeIds = new Set(), + rendererGeneration?: string | null ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { @@ -21,6 +22,44 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr // new object, and the accept gate below drops semantically-unchanged // renderer resends before they replace an entry — so reference identity // before/after detects exactly the entries that actually changed. + const blockedRecreatedWorktreeIds = new Set() + const acceptedSnapshots = snapshots.filter((snapshot) => { + const fence = this.removedMobileSessionWorktreeIds.get(snapshot.worktree) + if (!fence) { + return true + } + const reject = (): false => { + blockedRecreatedWorktreeIds.add(snapshot.worktree) + fence.rejectedPublication = true + return false + } + const currentMeta = this.store?.getWorktreeMeta(snapshot.worktree) + if (!currentMeta) { + return reject() + } + if (snapshot.worktreeInstanceId !== undefined) { + // Why: every catalog row carries an instanceId, so a mismatch against the + // live meta is exactly "not the current occupant" — no removed-id memory needed. + if (snapshot.worktreeInstanceId !== currentMeta.instanceId) { + return reject() + } + // Why: the successor's identity proves the race window closed; the + // instanceId mismatch alone fences any later frame from the old occupant. + this.removedMobileSessionWorktreeIds.delete(snapshot.worktree) + return true + } + // Identity-less frame: only the live renderer generation can speak for the + // successor, and the generation that published the removed occupant never + // can — a same-generation recreate stays fenced until the renderer reloads. + if ( + (typeof rendererGeneration === 'string' && + snapshot.publicationEpoch !== rendererGeneration) || + snapshot.publicationEpoch === fence.removedPublicationEpoch + ) { + return reject() + } + return true + }) const before = new Map(this.mobileSessionTabsByWorktree) this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, { missingSnapshotOnly: true, @@ -31,7 +70,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend)) ) if (this.offscreenBrowserBackend) { - for (const snapshot of snapshots) { + for (const snapshot of acceptedSnapshots) { if (!worktreeSessionsToHydrate.has(snapshot.worktree)) { worktreeSessionsToHydrate.set(snapshot.worktree, null) } @@ -46,7 +85,10 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr }) } const nextWorktrees = new Set() - const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) + const incomingWorktreeIds = new Set(acceptedSnapshots.map((snapshot) => snapshot.worktree)) + for (const worktreeId of blockedRecreatedWorktreeIds) { + nextWorktrees.add(worktreeId) + } // Why: the renderer withholds unchanged snapshots to keep the graph payload // small, so these worktrees are still live and must not fall into the prune // below. Ask for a republish when main no longer holds that accepted renderer @@ -57,6 +99,12 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr if (existing) { nextWorktrees.add(worktreeId) } + // Why: a fenced frame stays "published" renderer-side; asking for a + // republish would only be fenced again on every sync. + if (!existing && this.removedMobileSessionWorktreeIds.get(worktreeId)?.rejectedPublication) { + nextWorktrees.add(worktreeId) + continue + } if ( existing && accepted && @@ -78,7 +126,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr // which outlives the dropped snapshot and would reject the republish. this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) } - for (const snapshot of snapshots) { + for (const snapshot of acceptedSnapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) // Why: judge renderer publication ordering against the renderer's own diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 7f5a2bf7973..f9c6ab358e3 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -79,7 +79,8 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow const changedMobileWorktrees = this.syncMobileSessionTabs( graph.mobileSessionTabs, graph.unchangedMobileSessionWorktrees, - mobileSessionResyncWorktrees + mobileSessionResyncWorktrees, + rendererGeneration ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 6d689ba7e3f..a1743a855ce 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -40,7 +40,14 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC clientMutationId, async () => { if (reconcileExisting) { - const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle) + const adopted = await this.reconcileRemoteTerminalCreate( + workspace.id, + preAllocatedHandle, + // Why: an unreachable SSH host vanishes from the aggregate listing, which would read + // as absence and respawn over live remote work. Local/folder workspaces have no + // connection and keep the aggregate listing. + workspace.connectionId ?? null + ) if (adopted) { return adopted } @@ -52,13 +59,16 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC protected async reconcileRemoteTerminalCreate( worktreeId: string, - terminalHandle: string + terminalHandle: string, + // Why: an aggregate listing drops a non-answering SSH host silently, which would read as + // absence. Scoping to the owning host makes an unreachable relay throw instead. + connectionId?: string | null ): Promise { if (!this.ptyController?.listProcesses) { throw new Error('runtime_unavailable') } const listed = await withTimeoutResult( - this.ptyController.listProcesses(), + this.ptyController.listProcesses(connectionId), PTY_CONTROLLER_LIST_TIMEOUT_MS ) if (!listed.ok) { diff --git a/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts b/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts index 9afafb4272f..f8f8600dc1f 100644 --- a/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts +++ b/src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts @@ -10,14 +10,18 @@ type CreateRun = ( preAllocatedHandle: string | undefined ) => Promise -function createRuntimeForDedupe(listProcesses = vi.fn(async (): Promise => [])) { +function createRuntimeForDedupe( + listProcesses = vi.fn(async (): Promise => []), + scope: { connectionId?: string | null } = {} +) { const handleByPtyId = new Map() const runtime = Object.create(OrcaRuntimeService.prototype) as OrcaRuntimeService Object.assign(runtime, { terminalCreateIdempotency: new RemoteRuntimeTerminalCreateIdempotency(), ptyController: { listProcesses }, resolveTerminalWorkspaceLaunchScope: vi.fn(async (selector: string) => ({ - id: selector.startsWith('id:') ? selector.slice(3) : selector + id: selector.startsWith('id:') ? selector.slice(3) : selector, + ...scope })), adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => { handleByPtyId.set(ptyId, handle) @@ -253,3 +257,126 @@ describe('terminal create idempotency', () => { ).resolves.toEqual(createdTerminal('terminal-2')) }) }) + +// Mirrors listProcessesFromRuntimeController: `undefined` aggregates every provider and +// silently drops a non-answering SSH host, `null` is local-only, a string is host-scoped +// and rethrows the host's failure. +function createHostScopedInventory(hosts: { + local?: PtyProcessInfo[] + ssh?: Record +}) { + const local = hosts.local ?? [] + const ssh = hosts.ssh ?? {} + return vi.fn(async (connectionId?: string | null): Promise => { + if (connectionId === null) { + return local + } + if (typeof connectionId === 'string') { + const host = ssh[connectionId] + if (host === undefined || host === 'unreachable') { + throw new Error('ssh relay did not answer') + } + return host + } + return [ + ...local, + ...Object.values(ssh) + .filter((sessions): sessions is PtyProcessInfo[] => sessions !== 'unreachable') + .flat() + ] + }) +} + +function remoteSession(handle: string | undefined, worktreeId = 'worktree-1'): PtyProcessInfo { + return { + id: `${worktreeId}@@session-a`, + cwd: '/remote/workspace', + title: 'claude', + worktreeId, + ...(handle ? { terminalHandle: handle } : {}) + } +} + +describe('terminal create reconciliation scopes inventory to the owning execution host', () => { + it('reports runtime_unavailable instead of spawning a duplicate when the owning relay cannot answer', async () => { + const listProcesses = createHostScopedInventory({ + // The first create's shell is alive on ssh-1; the relay simply cannot be asked about it. + ssh: { 'ssh-1': 'unreachable', 'ssh-2': [remoteSession(undefined, 'worktree-9')] } + }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: 'ssh-1' }) + const create = vi.fn() + + await expect( + runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create) + ).rejects.toThrow('runtime_unavailable') + expect(create).not.toHaveBeenCalled() + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + }) + + it('adopts the original PTY from the owning host listing', async () => { + const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1') + const listProcesses = createHostScopedInventory({ ssh: { 'ssh-1': [remoteSession(handle)] } }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: 'ssh-1' }) + const create = vi.fn() + + await expect( + runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create) + ).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@session-a' }) + expect(create).not.toHaveBeenCalled() + }) + + it('still creates a fresh terminal when the owning host authoritatively lacks the handle', async () => { + const listProcesses = createHostScopedInventory({ + ssh: { 'ssh-1': [remoteSession(undefined, 'worktree-other')] } + }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: 'ssh-1' }) + const create = vi.fn(async (_selector, handle) => + createdTerminal(handle ?? 'missing') + ) + + const result = await runtime.dedupeTerminalCreate( + 'device-a', + 'id:worktree-1', + 'mutation-1', + true, + create + ) + + expect(create).toHaveBeenCalledWith('id:worktree-1', result.handle) + expect(listProcesses).toHaveBeenCalledWith('ssh-1') + }) + + it('scopes the listing to the local host for a workspace with no connection', async () => { + const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1') + const listProcesses = createHostScopedInventory({ + local: [{ ...remoteSession(handle), cwd: '/local/workspace', title: 'pwsh' }] + }) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: null }) + const create = vi.fn() + + await expect( + runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create) + ).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@session-a' }) + expect(listProcesses).toHaveBeenCalledWith(null) + expect(create).not.toHaveBeenCalled() + }) + + it('scopes the listing to the local host for a folder workspace with no connection', async () => { + const listProcesses = createHostScopedInventory({}) + const { runtime } = createRuntimeForDedupe(listProcesses, { connectionId: null }) + const create = vi.fn(async (_selector, handle) => + createdTerminal(handle ?? 'missing', 'folder:folder-1') + ) + + const result = await runtime.dedupeTerminalCreate( + 'device-a', + 'id:folder:folder-1', + 'mutation-1', + true, + create + ) + + expect(create).toHaveBeenCalledWith('id:folder:folder-1', result.handle) + expect(listProcesses).toHaveBeenCalledWith(null) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts index 964d69f64d8..6b09d1263b1 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities.spec.ts @@ -501,6 +501,23 @@ describe('OrcaRuntimeService', () => { expect(closeTab).toHaveBeenCalledTimes(2) }) + it('does not rescue a paired renderer PTY into a recreated worktree', () => { + const runtime = createRuntime() + const ptyId = 'paired-pty-deleted-worktree' + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId: 'tab-deleted-worktree', + leafId: 'leaf-deleted-worktree' + }) + const internals = runtime as unknown as { + pairedRendererSessionOwnedPtyIds: Set + } + internals.pairedRendererSessionOwnedPtyIds.add(ptyId) + + runtime['removeWorktreeMetadataAndHistory'](store as never, TEST_WORKTREE_ID) + + expect(internals.pairedRendererSessionOwnedPtyIds.has(ptyId)).toBe(false) + }) + it('closes a worktree’s client-hosted browser pages when its metadata is removed (leak fix)', async () => { const runtime = createRuntime() const host = attachClientBrowserHost(runtime) diff --git a/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts new file mode 100644 index 00000000000..a2e35baa4aa --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' +import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' +import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission' + +// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works. +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const EPOCH = 1_700_000_000_000 + +type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string } + +function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] { + return batches.flatMap((batch) => + batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title') + ) +} + +describe('decorative title fact throttle', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(EPOCH)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer + for (let tick = 0; tick < ticks; tick += 1) { + vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame( + 'pty-1', + `\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07` + ) + } + + const facts = titleFacts(batches) + // Every frame carried the same underlying title, so the renderer learns nothing new past + // the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window. + const elapsedMs = ticks * SPINNER_INTERVAL_MS + expect(facts.length).toBeLessThanOrEqual( + Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS) + ) + expect(facts.length).toBeLessThan(ticks / 5) + // The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window + // needs to cancel a milestone `done` — three working frames per window. + expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3) + for (const fact of facts) { + expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true) + } + }) + + it('propagates a real title change on the tick it arrives, mid-heartbeat', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + // Two more decorative ticks — still well inside the heartbeat window, so they are dropped. + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07') + expect(titleFacts(batches)).toHaveLength(1) + + const beforeChange = batches.length + vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + + expect(batches.length).toBeGreaterThan(beforeChange) + expect(titleFacts(batches.slice(beforeChange))).toEqual([ + { kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' } + ]) + }) + + it('propagates a changed working label immediately even while the spinner rotates', () => { + // Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07') + + expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([ + '⠋ Claude Code', + '⠙ Reviewing diff — Claude Code' + ]) + }) + + it('keeps main-side tracked title state current for every suppressed frame', () => { + // Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing + // the fact must not freeze what a phone or a paired client is shown. + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07') + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([ + { kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' } + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-settled-prompt-foreground-confirmation.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-settled-prompt-foreground-confirmation.spec.ts new file mode 100644 index 00000000000..84d55a7e1d2 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/terminal-settled-prompt-foreground-confirmation.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { store, syncSinglePty } from '../orca-runtime-test-fixtures.spec' + +// Why: node-pty's cached foreground name is p_comm on macOS, which reports the native Claude +// install as its version directory (`2.1.258`). Reading that as "no agent" silently downgraded +// `terminal.send --enter` from the atomic bracketed-paste route to unframed 16 KiB chunks, +// which Claude's composer truncates for large prompts (STA-4577). +describe('isTerminalRunningSettledPromptAgent foreground confirmation', () => { + // `2.1.258`: macOS p_comm for the native Claude install. `bash.exe`: the Windows daemon + // tracker answers with the shell fallback until its async scan lands. + it.each(['2.1.258', 'bash.exe'])( + 'confirms an unrecognized foreground (%s) before refusing the settled route', + async (cachedForeground) => { + const getForegroundProcess = vi.fn(async () => cachedForeground) + const confirmForegroundProcess = vi.fn(async () => 'claude') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'bash' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningSettledPromptAgent(terminal.handle)).resolves.toBe(true) + expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1') + // The confirmed identity is reused; the cached read must not be re-consulted and win. + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + } + ) + + it('keeps legacy delivery when confirmation also finds no target agent', async () => { + const confirmForegroundProcess = vi.fn(async () => 'vim') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => '2.1.258', + confirmForegroundProcess + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'bash' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningSettledPromptAgent(terminal.handle)).resolves.toBe(false) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it('does not confirm when the cached foreground already names a target agent', async () => { + const confirmForegroundProcess = vi.fn(async () => 'claude') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude', + confirmForegroundProcess + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'bash' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningSettledPromptAgent(terminal.handle)).resolves.toBe(true) + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('refuses the settled route when the provider cannot confirm', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => '2.1.258' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'bash' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningSettledPromptAgent(terminal.handle)).resolves.toBe(false) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index fb07f8a3d0e..79feca3268c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -8,6 +8,7 @@ import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission' describe('terminal side-effect fact channel', () => { it('defers desktop-only output scanners until a headless runtime is promoted', () => { @@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => { expect(events).toHaveLength(1) }) - it('bounds decorative title delivery per paired client without reducing local frames', () => { - const { runtime, batches } = createSideEffectRuntime() - const firstClientEvents: RuntimeClientEvent[] = [] - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) - runtime.onClientEvent((event) => firstClientEvents.push(event)) + it('bounds decorative title delivery per paired client below the local heartbeat', () => { + // Why the clock steps: main throttles decorative repeats on the local fact stream, so each + // round must clear that heartbeat for the per-client gate to be what collapses them here. + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { runtime, batches } = createSideEffectRuntime() + const firstClientEvents: RuntimeClientEvent[] = [] + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => firstClientEvents.push(event)) - const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) - } - firstClientEvents.length = 0 - - for (const frame of frames.slice(1)) { - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + const stepPastHeartbeat = (): void => { + vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS)) } + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + firstClientEvents.length = 0 + + for (const frame of frames.slice(1)) { + stepPastHeartbeat() + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + } + } + + expect(firstClientEvents).toEqual([]) + expect(batches).toHaveLength(ptyIds.length * frames.length) + + const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` + runtime.onPtyData(ptyIds[0], bellChunk, 1) + expect(firstClientEvents).toEqual([ + expect.objectContaining({ + type: 'terminalSideEffects', + batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) + }) + ]) + firstClientEvents.length = 0 + + const secondClientEvents: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => secondClientEvents.push(event)) + stepPastHeartbeat() + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + + expect(firstClientEvents).toEqual([]) + expect(secondClientEvents).toHaveLength(ptyIds.length) + + // A real title change is never throttled — no clock step needed. + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') + } + expect(firstClientEvents).toHaveLength(ptyIds.length) + expect(secondClientEvents).toHaveLength(ptyIds.length * 2) + } finally { + vi.useRealTimers() } - - expect(firstClientEvents).toEqual([]) - expect(batches).toHaveLength(ptyIds.length * frames.length) - - const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` - runtime.onPtyData(ptyIds[0], bellChunk, 1) - expect(firstClientEvents).toEqual([ - expect.objectContaining({ - type: 'terminalSideEffects', - batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) - }) - ]) - firstClientEvents.length = 0 - - const secondClientEvents: RuntimeClientEvent[] = [] - runtime.onClientEvent((event) => secondClientEvents.push(event)) - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) - } - - expect(firstClientEvents).toEqual([]) - expect(secondClientEvents).toHaveLength(ptyIds.length) - - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') - } - expect(firstClientEvents).toHaveLength(ptyIds.length) - expect(secondClientEvents).toHaveLength(ptyIds.length * 2) }) it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 64caa486013..65da9caccde 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -404,27 +404,36 @@ describe('OrcaRuntimeService', () => { wslDistro: 'Ubuntu' } ) - expect(gitSpy).toHaveBeenCalledWith( + // Why: a fork remote is deferred to first push/pull/fetch/fast-forward + // (#17828) instead of being added/fetched at create time, so the create + // path must not run check-ref-format, the fork fetch, or set-upstream-to + // -- the metadata is persisted untouched for on-demand materialization. + expect(gitSpy).not.toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/runtime-wsl'], - { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + expect.anything() ) - expect(gitSpy).toHaveBeenCalledWith( + expect(gitSpy).not.toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', '+refs/heads/contributor/runtime-wsl*:refs/remotes/pr-contributor-orca/contributor/runtime-wsl*' ], - { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + expect.anything() ) - expect(gitSpy).toHaveBeenCalledWith( + expect(gitSpy).not.toHaveBeenCalledWith( [ 'branch', '--set-upstream-to', 'pr-contributor-orca/contributor/runtime-wsl', 'runtime-wsl' ], - { cwd: createdWorktree.path, wslDistro: 'Ubuntu' } + expect.anything() ) + expect(result.worktree.pushTarget).toEqual({ + remoteName: 'pr-contributor-orca', + branchName: 'contributor/runtime-wsl', + remoteUrl: 'git@github.com:contributor/orca.git' + }) expect(listWorktrees).toHaveBeenCalledWith(TEST_REPO_PATH, { wslDistro: 'Ubuntu' }) } finally { gitSpy.mockRestore() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 054d7f09316..aabfeb899c8 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec') await import('./orca-runtime-tests/terminal-side-effect-facts.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec') +await import('./orca-runtime-tests/decorative-title-fact-throttle.spec') await import('./orca-runtime-tests/headless-snapshots.spec') await import('./orca-runtime-tests/headless-snapshots-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits.spec') @@ -56,6 +57,7 @@ await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-06.s await import('./orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec') await import('./orca-runtime-tests/terminal-handles-and-agent-status.spec') await import('./orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec') +await import('./orca-runtime-tests/terminal-settled-prompt-foreground-confirmation.spec') await import('./orca-runtime-tests/terminal-handles-and-agent-status-part-03.spec') await import('./orca-runtime-tests/terminal-handles-and-agent-status-part-04.spec') await import('./orca-runtime-tests/terminal-handles-and-agent-status-part-05.spec') diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts index 5e5dbe4e4ad..f2c829c1a9d 100644 --- a/src/main/runtime/rpc/methods/automation-schemas.ts +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -137,7 +137,9 @@ export const AutomationId = z.object({ export const AutomationRuns = z.object({ automationId: OptionalString, - expectedOwner: ExpectedOwner + expectedOwner: ExpectedOwner, + limit: OptionalPositiveInt, + cursor: OptionalString }) export const AutomationCreate = z.object({ diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index d972bf6a553..ab768559749 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -105,6 +105,25 @@ describe('automation RPC methods', () => { expect(runtime.listAutomationRuns).toHaveBeenCalledWith('auto-1', undefined) }) + it('returns a cursor page when the caller requests a bounded run history', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listAutomationRunsPage: vi.fn().mockReturnValue({ + runs: [{ id: 'run-100', automationId: 'auto-1' }], + nextCursor: '100' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AUTOMATION_METHODS }) + + await expect( + dispatcher.dispatch(makeRequest('automation.runs', { automationId: 'auto-1', limit: 100 })) + ).resolves.toMatchObject({ + ok: true, + result: { nextCursor: '100' } + }) + expect(runtime.listAutomationRunsPage).toHaveBeenCalledWith('auto-1', undefined, 100, undefined) + }) + it('rejects unknown providers and invalid schedules', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts index 3645df3c149..ff5daca315c 100644 --- a/src/main/runtime/rpc/methods/automations.ts +++ b/src/main/runtime/rpc/methods/automations.ts @@ -83,8 +83,16 @@ export const AUTOMATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'automation.runs', params: AutomationRuns, - handler: (params, { runtime }) => ({ - runs: runtime.listAutomationRuns(params.automationId, params.expectedOwner) - }) + handler: (params, { runtime }) => { + if (params.limit !== undefined || params.cursor !== undefined) { + return runtime.listAutomationRunsPage( + params.automationId, + params.expectedOwner, + params.limit, + params.cursor + ) + } + return { runs: runtime.listAutomationRuns(params.automationId, params.expectedOwner) } + } }) ] diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 02c93c14f6b..d6c08ad44cf 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -44,7 +44,13 @@ describe('client UI RPC pairing-local field seams', () => { manualRepoOrder: [ { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } ], - workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'] + workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'], + agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'], + agentsFilterRepoIds: ['repo-a'], + agentsShowChildAgents: true, + agentsCompactMode: false, + activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, + manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } } it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 79b923a55db..edb3f651c32 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -122,6 +122,10 @@ const UiUpdateFields = z showInactiveWorkspaces: z.boolean().optional(), workspaceHostScope: z.string().optional(), visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), + agentsVisibleHostIds: z.array(z.string()).nullable().optional(), + agentsFilterRepoIds: StringArray.optional(), + agentsShowChildAgents: z.boolean().optional(), + agentsCompactMode: z.boolean().optional(), workspaceHostOrder: z.array(z.string()).optional(), automationHostFilter: z .union([ @@ -171,6 +175,8 @@ const UiUpdateFields = z updateReassuranceSeen: z.boolean().optional(), osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), + activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(), + manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(), browserDefaultUrl: NullableString.optional(), browserDefaultSearchEngine: z .enum(['google', 'duckduckgo', 'bing', 'kagi']) diff --git a/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts new file mode 100644 index 00000000000..826d0c0f3f0 --- /dev/null +++ b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts @@ -0,0 +1,53 @@ +/** + * #12547: `files.listAll` did not declare `maxResults`, so "the client names its cap and a full page + * means there is more" was wired only on the Electron IPC hop. Web and mobile were saved incidentally, + * by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. + */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { FILE_METHODS } from './files' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('files.listAll page size', () => { + // Why #12547: `maxResults` was wired only on the Electron IPC hop, so "a full page means there is + // more" was true for a desktop client and incidental for web/mobile. Declaring it here is a new + // optional field (wire rule 1): an older host strips it and keeps its own default. + it('forwards a client-named page size for a selected worktree', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: 20_001 }) + ) + + expect(runtime.listRuntimeFiles).toHaveBeenCalledWith('id:wt-1', { + excludePaths: undefined, + maxResults: 20_001 + }) + expect(response).toMatchObject({ ok: true, result: ['src/index.ts'] }) + }) + + // Why refuse rather than fall back: no released client sends this field, so a malformed value is a + // bug in the caller, not skew — the same call `files.search` already makes for its own maxResults. + it('refuses a malformed page size instead of silently picking one', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + listRuntimeFiles: vi.fn().mockResolvedValue(['src/index.ts']) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('files.listAll', { worktree: 'id:wt-1', maxResults: -3 }) + ) + + expect(response).toMatchObject({ ok: false }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index d4aaae455bc..ef349a22f84 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -93,8 +93,13 @@ const FileSearch = WorktreeSelector.extend({ maxResults: z.number().int().positive().optional() }) +// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its +// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page +// means there is more" was true for desktop and merely incidental for web and mobile, which were +// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`. const FileListAll = WorktreeSelector.extend({ - excludePaths: z.array(z.string()).optional() + excludePaths: z.array(z.string()).optional(), + maxResults: z.number().int().positive().optional() }) const FileUnwatch = z.object({ @@ -236,6 +241,7 @@ export const FILE_METHODS: RpcAnyMethod[] = [ const maxContentBytes = remoteFileContentBudget(clientKind, requestId) return runtime.listRuntimeFiles(params.worktree, { excludePaths: params.excludePaths, + ...(params.maxResults === undefined ? {} : { maxResults: params.maxResults }), ...(signal === undefined ? {} : { signal }), ...(maxContentBytes === undefined ? {} : { maxContentBytes }) }) diff --git a/src/main/runtime/runtime-automation-controller.ts b/src/main/runtime/runtime-automation-controller.ts index c8c8b1504eb..d3ac55df438 100644 --- a/src/main/runtime/runtime-automation-controller.ts +++ b/src/main/runtime/runtime-automation-controller.ts @@ -15,6 +15,9 @@ import type { AutomationDestination } from '../../shared/automation-owner-precondition' import { runAutomationNowFenced } from '../automations/refused-manual-run' +import { paginateAutomationRuns } from '../../shared/automation-run-cursor' +import { hasRuntimeAutomationUpdateValue } from './runtime-automation-update-value' +import { assertAutomationRunContextMatchesTarget } from './runtime-automation-run-context' export type RuntimeAutomationCreateInput = Omit< AutomationCreateInput, @@ -72,6 +75,13 @@ export class RuntimeAutomationController { return this.store.listAutomationRuns(automationId) } + listRunsPage(automationId?: string, limit?: number, cursor?: string) { + if (this.store?.listAutomationRunsPage) { + return this.store.listAutomationRunsPage(automationId, limit, cursor) + } + return paginateAutomationRuns(this.listRuns(automationId), limit, cursor) + } + listForScope(params: AutomationListParams = {}): AutomationListResult { if (!this.store?.listAutomationsForScope) { throw new Error('runtime_unavailable') @@ -99,7 +109,7 @@ export class RuntimeAutomationController { throw new Error('runtime_unavailable') } const target = await this.resolveTarget(input) - this.assertRunContextMatchesTarget(input.runContext, target.repo) + assertAutomationRunContextMatchesTarget(input.runContext, target.repo) if (input.reuseSession && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } @@ -142,12 +152,12 @@ export class RuntimeAutomationController { const patch: AutomationUpdateInput = {} this.copyPatchValues(updates, patch) const targetChanged = - hasUpdateValue(updates, 'repo') || - hasUpdateValue(updates, 'workspace') || - hasUpdateValue(updates, 'workspaceMode') + hasRuntimeAutomationUpdateValue(updates, 'repo') || + hasRuntimeAutomationUpdateValue(updates, 'workspace') || + hasRuntimeAutomationUpdateValue(updates, 'workspaceMode') if (targetChanged) { const target = await this.resolveTarget(updates, current) - this.assertRunContextMatchesTarget(updates.runContext, target.repo) + assertAutomationRunContextMatchesTarget(updates.runContext, target.repo) if (patch.reuseSession === true && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } @@ -158,9 +168,13 @@ export class RuntimeAutomationController { patch.reuseSession = false } } - if (!targetChanged && hasUpdateValue(updates, 'runContext') && current.projectId) { + if ( + !targetChanged && + hasRuntimeAutomationUpdateValue(updates, 'runContext') && + current.projectId + ) { const repo = await this.resolvers.showRepo(`id:${current.projectId}`) - this.assertRunContextMatchesTarget(updates.runContext, repo) + assertAutomationRunContextMatchesTarget(updates.runContext, repo) } if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') @@ -225,7 +239,7 @@ export class RuntimeAutomationController { 'missedRunGraceMinutes' ] as const for (const key of keys) { - if (hasUpdateValue(updates, key)) { + if (hasRuntimeAutomationUpdateValue(updates, key)) { Object.assign(patch, { [key]: updates[key] }) } } @@ -292,25 +306,4 @@ export class RuntimeAutomationController { } return { projectId, workspaceMode: 'new_per_run', workspaceId: null, repo } } - - private assertRunContextMatchesTarget( - runContext: - | RuntimeAutomationCreateInput['runContext'] - | RuntimeAutomationUpdateInput['runContext'], - repo: Repo | null - ): void { - if (!runContext || !repo) { - return - } - if (runContext.repoId !== repo.id || runContext.path !== repo.path) { - throw new Error('Automation project does not match its run context.') - } - } -} - -function hasUpdateValue( - updates: RuntimeAutomationUpdateInput, - key: K -): boolean { - return Object.hasOwn(updates, key) && updates[key] !== undefined } diff --git a/src/main/runtime/runtime-automation-run-context.ts b/src/main/runtime/runtime-automation-run-context.ts new file mode 100644 index 00000000000..f69f1771156 --- /dev/null +++ b/src/main/runtime/runtime-automation-run-context.ts @@ -0,0 +1,19 @@ +import type { Repo } from '../../shared/repo-types' +import type { + RuntimeAutomationCreateInput, + RuntimeAutomationUpdateInput +} from './runtime-automation-controller' + +export function assertAutomationRunContextMatchesTarget( + runContext: + | RuntimeAutomationCreateInput['runContext'] + | RuntimeAutomationUpdateInput['runContext'], + repo: Repo | null +): void { + if (!runContext || !repo) { + return + } + if (runContext.repoId !== repo.id || runContext.path !== repo.path) { + throw new Error('Automation project does not match its run context.') + } +} diff --git a/src/main/runtime/runtime-automation-update-value.ts b/src/main/runtime/runtime-automation-update-value.ts new file mode 100644 index 00000000000..84f5a166c7b --- /dev/null +++ b/src/main/runtime/runtime-automation-update-value.ts @@ -0,0 +1,8 @@ +import type { RuntimeAutomationUpdateInput } from './runtime-automation-controller' + +export function hasRuntimeAutomationUpdateValue( + updates: RuntimeAutomationUpdateInput, + key: K +): boolean { + return Object.hasOwn(updates, key) && updates[key] !== undefined +} diff --git a/src/main/runtime/runtime-git-command-target.ts b/src/main/runtime/runtime-git-command-target.ts index 540fd86f5c5..47131ce7e63 100644 --- a/src/main/runtime/runtime-git-command-target.ts +++ b/src/main/runtime/runtime-git-command-target.ts @@ -1,6 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' -import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import type { GitPushTarget, GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { GitRuntimeOptions } from '../git/git-runtime-options' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue' @@ -22,6 +22,11 @@ export type RuntimeGitCommandHost = { /** `undefined` keeps cached metadata; `null` is the authoritative unlinked answer. */ getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined getWorktreeLinkedIssueMeta?(worktreeId: string): PullRequestLinkedIssueMeta | null | undefined + /** Why (#17828 review follow-up): RuntimeGitSyncCommands deliberately materializes with + * no store (avoids unrelated ownership-inheritance/refspec-migration side effects), so a + * lazily-minted remote still needs a way back into the store's `pushTarget.remoteCreated` + * for #17842's orphan sweep. Called only when materialize reports `remoteCreated: true`. */ + persistMaterializedPushTarget?(worktreeId: string, pushTarget: GitPushTarget): void } export function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions { diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index 7cc892bac75..f68b82aac79 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -9,11 +9,32 @@ import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' -import { localGitOptionsForTarget, type RuntimeGitCommandHost } from './runtime-git-command-target' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../ipc/worktree-remote' +import { + localGitOptionsForTarget, + type RuntimeGitCommandHost, + type RuntimeGitTarget +} from './runtime-git-command-target' export class RuntimeGitSyncCommands { constructor(private readonly host: RuntimeGitCommandHost) {} + // Why (#17828 review follow-up): this class deliberately materializes with no store (see + // the `undefined` args below) to avoid unrelated ownership-inheritance/refspec-migration + // side effects on the RPC path -- so persistence goes through the host callback instead, + // using `target.worktree.id` already resolved here rather than threading a store through. + private persistMaterializedPushTargetIfCreated( + target: RuntimeGitTarget, + materialized: GitPushTarget | undefined + ): void { + if (materialized?.remoteCreated) { + this.host.persistMaterializedPushTarget?.(target.worktree.id, materialized) + } + } + async abortRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -73,10 +94,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.fetchRemote(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.fetchRemote(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitFetch(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitFetch(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -111,10 +146,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pullBranch(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.pullBranch(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitPull(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitPull(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -131,10 +180,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.fastForwardBranch(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.fastForwardBranch(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitFastForward(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitFastForward(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -170,12 +233,26 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pushBranch(target.worktree.path, publish === true, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.pushBranch(target.worktree.path, publish === true, materializedPushTarget, { forceWithLease: forceWithLease === true }) return { ok: true } } - await gitPush(target.worktree.path, publish === true, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitPush(target.worktree.path, publish === true, materializedPushTarget, { forceWithLease: forceWithLease === true, ...localGitOptionsForTarget(target), admissionTier: 'interactive' diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 9875e5a07d0..4e4b3ebe0b5 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -2,10 +2,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { Repo } from '../../shared/repo-types' import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' -import { - configureCreatedWorktreePushTarget, - prepareWorktreePushTarget -} from '../ipc/worktree-remote' +import { configureCreatedWorktreePushTarget } from '../ipc/worktree-remote' import { addSparseWorktree, addWorktree, @@ -129,15 +126,11 @@ export async function createRuntimeLocalGitWorktree(args: { if (args.request.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } + // Why: defer the remote add + fetch (fork case) or the redundant re-fetch + // (same-repo case, already fetched while resolving the PR start point) to + // first use -- push/pull/fetch/fast-forward materialize it on demand + // (#17828). Metadata is persisted untouched; only the git mutation defers. const preparedPushTarget = args.request.pushTarget - ? await prepareWorktreePushTarget( - args.repo.path, - args.request.pushTarget, - args.store, - args.repo.id, - args.localWorktreeGitOptions - ) - : undefined const suggestLocalBaseRefUpdate = !args.settings.refreshLocalBaseRefOnWorktreeCreate && !args.settings.localBaseRefSuggestionDismissed && @@ -242,14 +235,18 @@ export async function createRuntimeLocalGitWorktree(args: { args.effectiveSanitizedName! ) } - const configuredPushTarget = preparedPushTarget - ? await configureCreatedWorktreePushTarget( - args.worktreePath, - args.branchName, - preparedPushTarget, - args.localWorktreeGitOptions - ) - : undefined + // Why: `--set-upstream-to` requires the remote to already exist -- safe for a + // same-repo target (its remote, e.g. `origin`, always exists) but not for a + // deferred fork remote, which is materialized lazily at first push/pull/fetch. + const configuredPushTarget = + preparedPushTarget && !preparedPushTarget.remoteUrl + ? await configureCreatedWorktreePushTarget( + args.worktreePath, + args.branchName, + preparedPushTarget, + args.localWorktreeGitOptions + ) + : preparedPushTarget const { created } = await resolveCreatedWorktree( args.repo.path, args.worktreePath, diff --git a/src/main/runtime/runtime-project-group-controller-folder-delete.test.ts b/src/main/runtime/runtime-project-group-controller-folder-delete.test.ts new file mode 100644 index 00000000000..1cb7209ba65 --- /dev/null +++ b/src/main/runtime/runtime-project-group-controller-folder-delete.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeProjectGroupController } from './runtime-project-group-controller' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' + +const workspace = { + id: 'ws-1', + projectGroupId: 'group-1', + folderPath: '/tmp/ws' +} as FolderWorkspace + +function createController( + resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null +) { + const removeFolderWorkspace = vi.fn(() => true) + const teardownFolderWorkspacePtys = vi.fn(async () => undefined) + const cleanupRemovedFolderWorkspaceState = vi.fn() + const notifyReposChanged = vi.fn() + const controller = new RuntimeProjectGroupController({ + getStore: () => ({ getFolderWorkspaces: () => [workspace], removeFolderWorkspace }) as never, + resolveRepo: async () => { + throw new Error('unused') + }, + notifyReposChanged, + resolveFolderConnectionId, + teardownFolderWorkspacePtys, + cleanupRemovedFolderWorkspaceState + }) + return { + controller, + removeFolderWorkspace, + teardownFolderWorkspacePtys, + cleanupRemovedFolderWorkspaceState, + notifyReposChanged + } +} + +describe('RuntimeProjectGroupController.deleteFolderWorkspace', () => { + it('tears down PTYs and runtime state before removing the catalog row', async () => { + const deps = createController(() => 'ssh-1') + + await expect(deps.controller.deleteFolderWorkspace('ws-1')).resolves.toEqual({ deleted: true }) + + expect(deps.teardownFolderWorkspacePtys).toHaveBeenCalledWith('folder:ws-1', 'ssh-1') + expect(deps.cleanupRemovedFolderWorkspaceState).toHaveBeenCalledWith('folder:ws-1') + expect(deps.teardownFolderWorkspacePtys.mock.invocationCallOrder[0]).toBeLessThan( + deps.removeFolderWorkspace.mock.invocationCallOrder[0]! + ) + expect(deps.notifyReposChanged).toHaveBeenCalledTimes(1) + }) + + it('still deletes when the folder host is ambiguous, skipping only the PTY sweep', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const deps = createController(() => { + throw new Error('folder_workspace_connection_ambiguous') + }) + + await expect(deps.controller.deleteFolderWorkspace('ws-1')).resolves.toEqual({ deleted: true }) + + expect(deps.teardownFolderWorkspacePtys).not.toHaveBeenCalled() + expect(deps.cleanupRemovedFolderWorkspaceState).toHaveBeenCalledWith('folder:ws-1') + expect(deps.removeFolderWorkspace).toHaveBeenCalledWith('ws-1') + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 05d12c43573..2530ef9ad15 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -12,11 +12,15 @@ import { } from '../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { RuntimeStore } from './runtime-store-contract' +import { folderWorkspaceKey } from '../../shared/workspace-scope' type RuntimeProjectGroupDependencies = { getStore: () => RuntimeStore | null resolveRepo: (selector: string) => Promise notifyReposChanged: () => void + resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null + teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise + cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void } type FolderWorkspaceUpdates = Partial< @@ -211,6 +215,22 @@ export class RuntimeProjectGroupController { if (!store?.removeFolderWorkspace) { throw new Error('runtime_unavailable') } + const workspace = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId) + if (workspace) { + const worktreeId = folderWorkspaceKey(folderWorkspaceId) + // Why: a mixed-host group has no single PTY target; forgetting the + // workspace must still succeed, so skip the sweep instead of failing. + let connectionId: string | null | undefined + try { + connectionId = this.deps.resolveFolderConnectionId(workspace) + } catch (error) { + console.warn(`[folder-workspace] skipping PTY teardown for ${worktreeId}:`, error) + } + if (connectionId !== undefined) { + await this.deps.teardownFolderWorkspacePtys(worktreeId, connectionId) + } + this.deps.cleanupRemovedFolderWorkspaceState(worktreeId) + } const deleted = store.removeFolderWorkspace(folderWorkspaceId) if (deleted) { this.deps.notifyReposChanged() diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index e160e039533..aece6a8e7ad 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -61,6 +61,7 @@ export type RuntimeStore = { automationOwnerPrecondition?: Store['automationOwnerPrecondition'] automationChangeSelector?: Store['automationChangeSelector'] listAutomationRuns?: Store['listAutomationRuns'] + listAutomationRunsPage?: Store['listAutomationRunsPage'] createAutomation?: Store['createAutomation'] updateAutomation?: Store['updateAutomation'] deleteAutomation?: Store['deleteAutomation'] diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index 86681e97688..e87520fccc8 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -30,6 +30,8 @@ type RuntimeTerminalAgentPresenceDependencies = { export type RuntimeTerminalAgentPresenceOptions = { retryForegroundWrappers?: boolean + /** Foreground identity the caller already confirmed; skips the provider's cached read. */ + foregroundProcess?: string | null } export class RuntimeTerminalAgentPresence { @@ -75,7 +77,7 @@ export class RuntimeTerminalAgentPresence { if (!leaf.ptyId) { return false } - const foreground = await this.deps.getForegroundProcess(leaf.ptyId) + const foreground = await this.readForegroundProcess(leaf.ptyId, options) if (!foreground) { return false } @@ -138,7 +140,7 @@ export class RuntimeTerminalAgentPresence { ) { return true } - const foreground = await this.deps.getForegroundProcess(pty.ptyId) + const foreground = await this.readForegroundProcess(pty.ptyId, options) if (!foreground) { return false } @@ -157,6 +159,16 @@ export class RuntimeTerminalAgentPresence { ) } + private async readForegroundProcess( + ptyId: string, + options: RuntimeTerminalAgentPresenceOptions + ): Promise { + if (options.foregroundProcess !== undefined) { + return options.foregroundProcess + } + return await this.deps.getForegroundProcess(ptyId) + } + private async isRecognizedForegroundAgentProcess( ptyId: string, foregroundProcess: string, diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index be6767d65c6..3ea47fd6598 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -54,9 +54,19 @@ export type TerminalCreateOptions = { deferMobileSessionPublish?: boolean } +/** Identity a fenced spawn can be re-found by in the execution host's own inventory. */ +export type AgentSessionCreateReclaimIdentity = { + worktreeId: string + connectionId: string | null + terminalHandle: string +} + export type AgentSessionCreateOperation = { fingerprint: string promise: Promise + // Why: a lost pty.spawn response leaves the host holding a live PTY the client + // never named; this is the name it was launched under, so a replay can adopt it. + reclaim: { identity?: AgentSessionCreateReclaimIdentity } } export type PtyForegroundAgentRefresh = { diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts new file mode 100644 index 00000000000..748225de225 --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +const { + materializeLocalMock, + materializeSshMock, + getSshGitProviderMock, + getLocalProjectWorktreeGitOptionsMock +} = vi.hoisted(() => ({ + materializeLocalMock: vi.fn(), + materializeSshMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn() +})) +vi.mock('../ipc/worktree-remote', () => ({ + materializeWorktreePushTargetRemote: materializeLocalMock, + materializeWorktreePushTargetRemoteSsh: materializeSshMock +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + +import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' + +const WORKTREE_PATH = '/repo/worktree' +const FORK_URL = 'git@github.com:contributor/orca.git' +const REPO_ID = 'repo-1' +const STORE = {} as Store +const LOCAL_REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo +const SSH_REPO = { id: REPO_ID, path: '/repo', connectionId: 'conn-1' } as unknown as Repo + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + ...overrides + } +} + +// Flush the fire-and-forget microtask queue so assertions see the dispatched call. +const flush = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +describe('triggerTerminalSpawnPushTargetMaterialization', () => { + let warnSpy: ReturnType + + beforeEach(() => { + materializeLocalMock.mockReset().mockResolvedValue(undefined) + materializeSshMock.mockReset().mockResolvedValue(undefined) + getSshGitProviderMock.mockReset() + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('is a no-op when there is no push target', () => { + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, undefined, LOCAL_REPO, STORE) + expect(materializeLocalMock).not.toHaveBeenCalled() + expect(materializeSshMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a same-repo push target with no remoteUrl', () => { + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + forkTarget({ remoteUrl: undefined }), + LOCAL_REPO, + STORE + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('is a no-op when the target already reports remoteCreated', () => { + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + forkTarget({ remoteCreated: true }), + LOCAL_REPO, + STORE + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('materializes over the local transport with resolved WSL git options, repoId and worktreeId, fire-and-forget', () => { + getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + const target = forkTarget() + const result = triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + target, + LOCAL_REPO, + STORE, + REPO_ID, + 'worktree-1' + ) + expect(result).toBeUndefined() + expect(getLocalProjectWorktreeGitOptionsMock).toHaveBeenCalledWith(STORE, LOCAL_REPO) + expect(materializeLocalMock).toHaveBeenCalledWith( + WORKTREE_PATH, + target, + STORE, + REPO_ID, + { wslDistro: 'Ubuntu' }, + 'worktree-1' + ) + expect(materializeSshMock).not.toHaveBeenCalled() + }) + + it('materializes over SSH when the repo has a connectionId and a provider is registered', () => { + const provider = { exec: vi.fn() } + getSshGitProviderMock.mockReturnValue(provider) + const target = forkTarget() + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + target, + SSH_REPO, + STORE, + REPO_ID, + 'worktree-1' + ) + expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') + expect(materializeSshMock).toHaveBeenCalledWith( + provider, + WORKTREE_PATH, + target, + STORE, + undefined, + 'worktree-1' + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + expect(getLocalProjectWorktreeGitOptionsMock).not.toHaveBeenCalled() + }) + + it('is a no-op when the SSH connection has dropped (no registered provider)', () => { + getSshGitProviderMock.mockReturnValue(undefined) + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE) + expect(materializeSshMock).not.toHaveBeenCalled() + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('falls back to default git options when WSL project runtime resolution throws', () => { + getLocalProjectWorktreeGitOptionsMock.mockImplementation(() => { + throw new Error('repair-required') + }) + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), LOCAL_REPO, STORE) + expect(materializeLocalMock).toHaveBeenCalledWith( + WORKTREE_PATH, + forkTarget(), + STORE, + undefined, + {}, + undefined + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to resolve local git options'), + expect.any(Error) + ) + }) + + it('swallows a materialize rejection instead of crashing the caller', async () => { + materializeLocalMock.mockRejectedValue(new Error('remote add failed')) + expect(() => + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), LOCAL_REPO, STORE) + ).not.toThrow() + await flush() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to materialize push target remote'), + expect.any(Error) + ) + }) +}) diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts new file mode 100644 index 00000000000..958ccb99d14 --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts @@ -0,0 +1,84 @@ +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../ipc/worktree-remote' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +// Why (#17828): a fork-PR remote deferred at worktree-create time must exist before an +// autonomous agent's raw git commands run in a freshly opened terminal -- "sync through +// Orca first" isn't an option mid-task. Fires on every terminal spawn into the worktree; +// materialize() is already a no-op once the remote exists, so repeat spawns cost one probe. +// Never awaited by callers: terminal spawn must not block on remote-add/fetch network I/O. +export function triggerTerminalSpawnPushTargetMaterialization( + worktreePath: string, + pushTarget: GitPushTarget | undefined, + repo: Repo | null | undefined, + store: Store | undefined, + repoId?: string, + worktreeId?: string +): void { + if (!pushTarget?.remoteUrl || pushTarget.remoteCreated) { + return + } + const connectionId = repo?.connectionId ?? undefined + const materialized = connectionId + ? materializeOverSsh(connectionId, worktreePath, pushTarget, store, worktreeId) + : materializeWorktreePushTargetRemote( + worktreePath, + pushTarget, + store, + repoId, + localGitOptionsForTerminalSpawn(store, repo), + worktreeId + ) + materialized.catch((error: unknown) => { + console.warn( + `[terminal-spawn] failed to materialize push target remote for ${worktreePath}:`, + error + ) + }) +} + +function materializeOverSsh( + connectionId: string, + worktreePath: string, + pushTarget: GitPushTarget, + store: Store | undefined, + worktreeId: string | undefined +): Promise { + const provider = getSshGitProvider(connectionId) + if (!provider) { + // Why: connection dropped -- the next Orca-driven sync action will retry via its own dispatch. + return Promise.resolve(pushTarget) + } + return materializeWorktreePushTargetRemoteSsh( + provider, + worktreePath, + pushTarget, + store, + undefined, + worktreeId + ) +} + +function localGitOptionsForTerminalSpawn( + store: Store | undefined, + repo: Repo | null | undefined +): { wslDistro?: string } { + if (!store || !repo) { + return {} + } + try { + // Why: a WSL-hosted repo's remote add/fetch must run under the same distro as + // the terminal, or it can target the wrong git binary entirely (repair-required + // project runtimes throw here -- fall back to host git rather than crash spawn). + return getLocalProjectWorktreeGitOptions(store, repo) + } catch (error) { + console.warn(`[terminal-spawn] failed to resolve local git options for ${repo.path}:`, error) + return {} + } +} diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 68be05b8ad1..6ccb4ed82bb 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker applyingChunk: boolean lastMobileTitleGateKey: string | null + /** When the last title fact was emitted — throttles decorative-only repeats. */ + lastTitleFactAtMs: number | null chunkTouchedSessionTabs: boolean pendingFacts: TerminalSideEffectFact[] commandCodeDetector: { observe: (data: string) => boolean } | null diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts index e85294611f3..4324f8c5a26 100644 --- a/src/main/runtime/structured-tui-process-identity.test.ts +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -60,8 +60,18 @@ describe('structured TUI process identity', () => { platform: 'darwin', readPosixRows: async () => [ { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' }, - { pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' } + { + pid: 101, + ppid: 100, + stat: 'S+', + command: 'node /opt/codex/bin/codex resume abc' + }, + { + pid: 102, + ppid: 101, + stat: 'S+', + command: '/opt/codex/vendor/codex' + } ], readStartTime }) @@ -83,9 +93,27 @@ describe('structured TUI process identity', () => { agent: 'codex', platform: 'win32', readWindowsRows: async () => [ - { pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' }, - { pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' }, - { pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' } + { + pid: 100, + ppid: 1, + name: 'pwsh.exe', + command: 'pwsh.exe', + executablePath: '' + }, + { + pid: 101, + ppid: 100, + name: 'codex.exe', + command: 'codex resume a', + executablePath: '' + }, + { + pid: 102, + ppid: 100, + name: 'codex.exe', + command: 'codex resume b', + executablePath: '' + } ], timeoutMs: 0 }) @@ -107,7 +135,14 @@ describe('structured TUI process identity', () => { return [ { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, ...(snapshots >= 3 - ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'codex resume session-1' }] + ? [ + { + pid: 101, + ppid: 100, + stat: 'S+', + command: 'codex resume session-1' + } + ] : []) ] }, @@ -128,6 +163,81 @@ describe('structured TUI process identity', () => { expect(delays).toEqual([25, 25]) }) + // Each poll forks a whole-machine `ps` (~0.065 CPU-s at 1,460 processes), so the + // capture COUNT per identification is the cost, not the 5s wall ceiling. + function countCapturesForIdentification(input: { + captureCostMs: number + childAppearsAtMs: number | null + }): Promise<{ + captures: number + identifiedAtMs: number | null + elapsedMs: number + }> { + let clockMs = 0 + let captures = 0 + return readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-cost', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => { + captures += 1 + clockMs += input.captureCostMs + return [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + ...(input.childAppearsAtMs !== null && clockMs >= input.childAppearsAtMs + ? [ + { + pid: 101, + ppid: 100, + stat: 'S+', + command: 'codex resume session-1' + } + ] + : []) + ] + }, + readStartTime: async () => 1_700_000_000_000, + now: () => clockMs, + sleep: async (delayMs) => { + clockMs += delayMs + } + }).then( + () => ({ captures, identifiedAtMs: clockMs, elapsedMs: clockMs }), + () => ({ captures, identifiedAtMs: null, elapsedMs: clockMs }) + ) + } + + it('does not spend a hundred ps captures on an identification that never resolves', async () => { + const { captures, identifiedAtMs, elapsedMs } = await countCapturesForIdentification({ + captureCostMs: 55, + childAppearsAtMs: null + }) + + expect(identifiedAtMs).toBeNull() + // The 5s ceiling is unchanged; only the captures inside it are. + expect(elapsedMs).toBeGreaterThanOrEqual(5_000) + // A flat 50ms poll spends ~48 captures here. + expect(captures).toBeLessThanOrEqual(20) + }) + + it('keeps identification latency identical while a child can still plausibly appear', async () => { + // The backoff must not touch the window a real spawn lands in: same capture + // count and same detection time as the flat 50ms poll. + for (const childAppearsAtMs of [0, 200, 500, 900]) { + const flatPollCaptures = Math.max(1, Math.ceil(childAppearsAtMs / (55 + 50)) + 1) + const { captures, identifiedAtMs } = await countCapturesForIdentification({ + captureCostMs: 55, + childAppearsAtMs + }) + + expect(identifiedAtMs).not.toBeNull() + expect(captures).toBeLessThanOrEqual(flatPollCaptures) + expect(identifiedAtMs!).toBeLessThanOrEqual(childAppearsAtMs + 55 + 50) + } + }) + it('fails closed when the process snapshot omitted the PTY root', async () => { await expect( readStructuredTuiProcessIdentity({ diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts index 031b29f2f0f..d973f6c3f85 100644 --- a/src/main/runtime/structured-tui-process-identity.ts +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -15,6 +15,13 @@ type ProcessRow = { pid: number; ppid: number; command: string; foreground: bool const STRUCTURED_TUI_PROCESS_WAIT_MS = 5_000 const STRUCTURED_TUI_PROCESS_POLL_MS = 50 +// Why: every poll forks a whole-machine `ps` (~0.065 CPU-s at 1,460 processes), +// and the 5s ceiling is only reached when the child never appears at all — so the +// tight interval buys nothing there. Hold it for the window in which a spawning +// child plausibly lands (detection latency byte-identical), then widen. Past the +// window the added latency is bounded by one interval. +const STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS = 1_000 +const STRUCTURED_TUI_PROCESS_MAX_POLL_MS = 500 function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { const children = new Map() @@ -153,7 +160,9 @@ export async function readStructuredTuiProcessIdentity(input: { const platform = input.platform ?? process.platform const now = input.now ?? Date.now const sleep = input.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) - const deadline = now() + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) + const startedAtMs = now() + const deadline = startedAtMs + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) + let pollDelayMs = input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS while (true) { const rows: ProcessRow[] = @@ -194,6 +203,13 @@ export async function readStructuredTuiProcessIdentity(input: { const label = input.agent === 'codex' ? 'Codex' : 'Claude' throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) } - await sleep(Math.min(input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS, remainingMs)) + await sleep(Math.min(pollDelayMs, remainingMs)) + if (now() - startedAtMs >= STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS) { + // Never below the caller's interval, so an explicitly slow poll stays slow. + pollDelayMs = Math.max( + pollDelayMs, + Math.min(pollDelayMs * 2, STRUCTURED_TUI_PROCESS_MAX_POLL_MS) + ) + } } } diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index 4ef015cee27..7d160ba704b 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -875,41 +875,45 @@ describe('read-only skill freshness inventory', () => { ]) }) - it('invents no installations when the plugin cache trips the entry budget (#10918)', async () => { - const test = await fixture() - await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache') - await mkdir(pluginCache, { recursive: true }) - // Why: the production bound, not an injected one — #10918 is the real constant - // collapsing the scan to the cache root, and only a real cache proves that path. - const entries = Array.from({ length: MAXIMUM_PLUGIN_SCAN_ENTRIES + 1 }, (_, index) => - join(pluginCache, `entry-${index}`) - ) - for (let index = 0; index < entries.length; index += 512) { - await Promise.all(entries.slice(index, index + 512).map((path) => writeFile(path, ''))) - } + it.skipIf(process.platform === 'win32')( + 'invents no installations when the plugin cache trips the entry budget (#10918)', + async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + const pluginCache = join(test.homeDir, '.codex', 'plugins', 'cache') + await mkdir(pluginCache, { recursive: true }) + // Why: the production bound, not an injected one — #10918 is the real constant + // collapsing the scan to the cache root, and only a real cache proves that path. + const entries = Array.from({ length: MAXIMUM_PLUGIN_SCAN_ENTRIES + 1 }, (_, index) => + join(pluginCache, `entry-${index}`) + ) + for (let index = 0; index < entries.length; index += 512) { + await Promise.all(entries.slice(index, index + 512).map((path) => writeFile(path, ''))) + } - const inventory = await inventorySkillFreshness({ - currentAppVersion: '2.0.0', - homeDir: test.homeDir, - repos: [], - resourceRoot: test.resourceRoot - }) - - // Why: assert the bound actually tripped first — if the fixture stopped reaching it, - // the placement assertion below would still pass and cover nothing. - expect(inventory.scanIssues).toEqual([ - expect.objectContaining({ - rootId: 'codex-plugin-cache', - path: pluginCache, - reason: 'entry-limit', - errorCode: null + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot }) - ]) - // Why: the truncated root is not evidence of a copy. Fabricating one per manifest name - // is what pinned an unclearable "Needs attention" on every card in #10918. - expect(inventory.installations).toEqual([ - expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' }) - ]) - }, 90_000) + + // Why: assert the bound actually tripped first — if the fixture stopped reaching it, + // the placement assertion below would still pass and cover nothing. + expect(inventory.scanIssues).toEqual([ + expect.objectContaining({ + rootId: 'codex-plugin-cache', + path: pluginCache, + reason: 'entry-limit', + errorCode: null + }) + ]) + // Why: the truncated root is not evidence of a copy. Fabricating one per manifest name + // is what pinned an unclearable "Needs attention" on every card in #10918. + expect(inventory.installations).toEqual([ + expect.objectContaining({ name: 'orca-cli', status: 'current', topology: 'canonical-copy' }) + ]) + }, + 90_000 + ) }) diff --git a/src/main/ssh/remote-install-gc.ts b/src/main/ssh/remote-install-gc.ts index b14b7e11fba..a76d119cf8a 100644 --- a/src/main/ssh/remote-install-gc.ts +++ b/src/main/ssh/remote-install-gc.ts @@ -22,6 +22,7 @@ import { tryAcquireRelayGcClaim } from './ssh-relay-gc-claim' import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' +import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' import { listRemoteInstallBaseDirsCommand, MAX_RELAY_GC_LISTING_ENTRIES, @@ -245,12 +246,25 @@ export async function gcOldRelayVersions( options?: { windowsNodePath?: string windowsSockNames?: string[] + /** + * Cache entries this connection depends on, whether or not it links to them. Also the gate: + * a caller that could not compute a key is not using the shared-cache model on this host, and + * a pass only ever collects what its own model created (see `remote-install-model.ts`). + */ + nativeDepsCacheKeys?: readonly string[] } ): Promise { await gcOldRemoteInstallVersions(conn, RELAY_INSTALL_MODEL, remoteHome, currentDirAbsPath, host, { ...options, isDirLive: (dir) => hasLiveRelaySocket(conn, dir, host, options) }) + // Why after and not before: version-dir removal is what turns a cache entry unreferenced, so + // running it second lets one pass reclaim both instead of leaving the tree for the next connect. + if (options?.nativeDepsCacheKeys?.length) { + await gcRelayNativeDepsCache(conn, host, remoteHome, { + pinnedKeys: options.nativeDepsCacheKeys + }).catch(() => {}) + } } async function hasLiveRelaySocket( diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index acda4594ca2..f17fc858164 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -550,6 +550,27 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('hands a zero-exit command stderr to onStderr instead of dropping it', async () => { + // Why: probes fenced with `|| echo MISSING` always exit 0, so the resolve path used to be the + // one place the failure reason was discarded. + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const captured: string[] = [] + const commandPromise = execCommand(conn as never, "(node -e 'x' || echo MISSING)", { + onStderr: (stderr) => captured.push(stderr) + }) + + await Promise.resolve() + channel.stderr.emit('data', Buffer.from('node: --bogus is not allowed in NODE_OPTIONS\n')) + channel.emit('data', Buffer.from('MISSING\n')) + channel.emit('close', 0) + + await expect(commandPromise).resolves.toBe('MISSING\n') + expect(captured).toEqual(['node: --bogus is not allowed in NODE_OPTIONS\n']) + // onStderr must not leak into the SSH exec options. + expect(conn.exec).toHaveBeenCalledWith("(node -e 'x' || echo MISSING)", {}) + }) + it('uses custom command timeouts without forwarding them to SSH exec', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 3134461fda1..fdd719cb91f 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -186,9 +186,9 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) - it('does not launch fresh after unconfirmed stale-socket cleanup', async () => { + it('does not launch fresh after an unconfirmed endpoint-incumbent probe', async () => { const conn = makeMockConnection() - const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), { + const unconfirmedCleanup = Object.assign(new Error('endpoint probe still running'), { sshChannelCloseConfirmed: false }) vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 911d185b0fd..fc65af35c0a 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -33,6 +33,12 @@ import { abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { + attachRelayNativeDepsCache, + promoteRelayNativeDepsCache, + resolveRelayNativeDepsCacheKey, + type RelayNativeDepsCacheContext +} from './ssh-relay-native-deps-cache-install' import { acquireInstallLock } from './ssh-relay-install-lock' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { @@ -77,6 +83,8 @@ import { import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' +import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints' import { isSshSessionLimitError } from './ssh-session-limit-error' import { isWindowsRelayPipePath, @@ -116,12 +124,13 @@ function execHostCommand( conn: SshConnection, hostPlatform: RemoteHostPlatform, command: string, - options?: { timeoutMs?: number; signal?: AbortSignal } + options?: { timeoutMs?: number; signal?: AbortSignal; onStderr?: (stderr: string) => void } ): Promise { return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform), timeoutMs: options?.timeoutMs, - signal: options?.signal + signal: options?.signal, + onStderr: options?.onStderr }) } @@ -519,7 +528,8 @@ async function deployAndLaunchRelayAttempt( nodePath, deploySignal, [], - launchNamespace + launchNamespace, + remoteHome ) console.log('[ssh-relay] Native deps installed') @@ -580,11 +590,31 @@ async function deployAndLaunchRelayAttempt( hostPlatform, recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) ) + .catch(() => {}) + // Why before GC: a superseded relay pins its version dir via the live-socket probe, so the + // sweep has to settle first or GC keeps every orphan's tree forever. + .then(() => + sweepSupersededRelayEndpoints(conn, hostPlatform, { + remoteHome, + currentRelayDir: remoteRelayDir, + sockName: relaySocketNameForInstanceId(relayInstanceId), + nodePath: launched.nodePath + }) + ) .catch(() => {}) .then(() => gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)] + windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)], + // Why pin rather than rely on the symlink alone: a deploy that fell back to a + // per-directory install has no reference to show, and its key must still survive. + nativeDepsCacheKeys: [ + resolveRelayNativeDepsCacheKey({ + platform, + localRelayDir, + deps: RELAY_NATIVE_DEPS + }) + ].filter((key): key is string => key !== null) }) ) .catch(() => {}) @@ -697,6 +727,31 @@ function uploadStageNamespaceIfSupported( const NODE_PTY_VERSION = '1.1.0' const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' +const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' +const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' +/** + * Whether the tree the patch left behind still leaks the pty master into every later child. + * `fixed` is the only outcome a shared cache entry may be published from. + */ +type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed' +/** + * The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist: + * the script's `skipped:` family is mixed. `skipped:not-linux` is a platform that never leaks, but + * `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:unexpected-source` and + * the two `skipped:` forms all mean the patch was refused and the leaky build is still on + * disk -- indistinguishable from `failed:` as far as what gets published. + */ +const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet = new Set([ + 'patched', + // The rebuild ran from patched source; only the isolation check could not observe the result. + // An unobservable check is not a failed patch, and treating it as one would disable the shared + // cache on every host without `lsof`. + 'patched-unverified', + 'already-patched', + // Unreachable while the platform gate below short-circuits first, but it is the one `skipped:` + // that means "nothing to fix" rather than "would not fix it". + 'skipped:not-linux' +]) // Exported for the relay-native-dependency-coverage test, which asserts every // native addon the relay bundle imports is either installed here or explicitly // declared as degrading without it. @@ -722,24 +777,34 @@ function nativeDepsProbeJs(successToken: string): string { return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` } -function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { +/** + * Which deps the probe *named* as unloadable, or `undefined` when the answer names none. + * + * Only the probe's own marker line is evidence about the deps. An answer without one (node never + * ran, was killed, exited before the script) says nothing, so it must not be read as "all of them" — + * that inference deleted both native modules on every reconnect of an affected host. + */ +function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] | undefined { const marker = output .split(/\r?\n/) .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) if (!marker) { - return [...RELAY_NATIVE_DEP_NAMES] + return undefined } const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') - return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) + const named = RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) + return named.length > 0 ? named : undefined } /** - * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered and named deps - * that failed to load. `unverifiable` — the probe never answered, which is evidence about the - * transport, not about the deps. + * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered with a marker + * naming deps that failed to load. `unverifiable` — the probe never answered, or answered nothing + * that names a dep; both are evidence about the probe, not about the deps. * * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a - * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. + * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. An + * unparseable answer is the worse half of that — it is deterministic and per-host, so a node that + * cannot start (bad NODE_OPTIONS, OOM, exit 127) deleted both modules on every reconnect forever. * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. */ @@ -754,6 +819,7 @@ async function probeRequiredNativeDeps( ): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') + let probeStderr = '' try { const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( @@ -762,16 +828,32 @@ async function probeRequiredNativeDeps( remoteDir, `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} } catch { 'MISSING' }` ) - : commandWithNodePath( + : // Why: no `2>/dev/null` — it discarded the only line that says why node never reached the + // script. stderr stays its own stream so it can't be mistaken for the verdict, mirroring + // src/main/orcad/node-pty-precondition.ts. + commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` + `(${escapedNode} -e ${shellEscape(probeJs)} || echo MISSING)` ) - const probe = await execHostCommand(conn, hostPlatform, command, { signal }) - return probe.includes('ORCA-NATIVE-DEPS-OK') - ? { status: 'ok', missing: [] } - : { status: 'blocked', missing: missingNativeDepsFromProbe(probe) } + const probe = await execHostCommand(conn, hostPlatform, command, { + signal, + onStderr: (text) => { + probeStderr = text + } + }) + if (probe.includes('ORCA-NATIVE-DEPS-OK')) { + return { status: 'ok', missing: [] } + } + const missing = missingNativeDepsFromProbe(probe) + if (!missing) { + console.warn( + `[ssh-relay][NATIVE-DEPS-PROBE-UNPARSEABLE] Probe at ${remoteDir} answered without naming a dep; launching as-is. stdout=${probe.trim().slice(-200)} stderr=${probeStderr.trim().slice(-500)}` + ) + return { status: 'unverifiable', missing: [] } + } + return { status: 'blocked', missing } } catch { signal?.throwIfAborted() // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and @@ -974,8 +1056,27 @@ async function installNativeDeps( nodePath: string, signal?: AbortSignal, resetDeps: RelayNativeDepName[] = [], - namespace?: RelayInstallNamespace + namespace?: RelayInstallNamespace, + remoteHome?: string ): Promise { + // Why a repair opts out: reset does `rm -rf node_modules/node-pty`, and through a shared + // symlink that is every relay on the host losing its addon. Repairs detach and install + // privately instead (the install command's own prefix drops the link). + const localRelayDir = resetDeps.length === 0 && remoteHome ? getLocalRelayPath(platform) : null + const cacheContext: RelayNativeDepsCacheContext | null = + remoteHome && localRelayDir + ? { + hostPlatform, + remoteHome, + relayDir: remoteDir, + platform, + localRelayDir, + deps: RELAY_NATIVE_DEPS, + signal + } + : null + const cache = cacheContext ? await attachRelayNativeDepsCache(conn, cacheContext) : null + const writeRelayPackageJson = async (deps: Record): Promise => { await writeRelayFile( conn, @@ -1003,13 +1104,32 @@ async function installNativeDeps( // Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module. await writeRelayPackageJson(RELAY_NATIVE_DEPS) + if (cache?.mode === 'linked') { + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + const linkedProbe = await probeInstalledNativeDeps( + conn, + remoteDir, + hostPlatform, + nodePath, + signal + ) + if (linkedProbe.available) { + return + } + // Why fall through rather than repair the entry: it is shared, and something else on this + // host may be running out of it right now. This directory installs its own copy instead. + console.warn( + `[ssh-relay][NATIVE-CACHE-UNUSABLE] shared entry ${cache.key} did not load at ${remoteDir} (${platform}); installing per-directory. stderr=${linkedProbe.stderr.trim().slice(-500)}` + ) + } + try { const installArgs = Object.entries(RELAY_NATIVE_DEPS) .map(([dep, version]) => shellEscape(`${dep}@${version}`)) .join(' ') // Why: npm reports a present package as up to date even if a native file was deleted; reset only deps the probe found broken. const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) - const resetPrefix = resetCommand ? `${resetCommand}; ` : '' + const resetPrefix = `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand ? `${resetCommand}; ` : ''}` const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, @@ -1118,6 +1238,37 @@ async function installNativeDeps( } } + // Why this precedes promotion: the patch renames `node-pty/build/Release`, runs `npm rebuild` + // and rolls back inside `node_modules`, and promotion turns that directory into a symlink to a + // published -- and by contract immutable -- shared cache entry. Patching afterwards would write + // through the link, and `.deps-complete` would already have published an unpatched tree that + // every later host links and skips. + const cloexec = probe.available + ? await applyNodePtyMasterCloexecPatch( + conn, + remoteDir, + platform, + hostPlatform, + nodePath, + signal + ) + : 'unfixed' + + // Why promotion is gated on the probe and not on npm's exit code: an entry is shared, so the + // only evidence worth publishing is this host having loaded both addons out of that tree. + // Why it is gated on the patch too: a refused or rolled-back patch leaves the pre-patch leaky + // build in place, and the cache key hashes this patch's bytes -- so publishing it would hand + // every later host on the machine a tree that links, probes loadable, and skips patching. + if (probe.available && cacheContext && cache) { + if (cloexec === 'fixed') { + await promoteRelayNativeDepsCache(conn, cacheContext, cache.key) + } else { + console.warn( + `[ssh-relay][NPTY-CLOEXEC-UNSHARED] keeping the native deps at ${remoteDir} (${platform}) private; the tree still leaks the pty master, so it is not publishable as ${cache.key}` + ) + } + } + // MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons. if (!probe.available) { console.warn( @@ -1126,6 +1277,90 @@ async function installNativeDeps( } } +/** + * Re-apply the pty-master FD_CLOEXEC patch the app gets from pnpm to the host's npm copy (#17915). + * + * Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a + * freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped + * -- replacing the file on disk does not touch the running process. It keeps the build it started + * with and picks up the patched one when it restarts. + * + * Why it is bounded: the remote script attempts the compile at most once per relay directory, and + * the directory is content-hashed over the relay manifest -- so at most one compile per bundle. + * + * Why a shared cache entry never reaches here: the caller returns as soon as a linked tree probes + * loadable, so this only ever rewrites a `node_modules` the relay directory still owns privately. + * + * Returns whether the tree that is left behind still leaks, which is what decides publishability. + * The script exits 0 on every outcome by design, so the status line is the only evidence there is. + */ +async function applyNodePtyMasterCloexecPatch( + conn: SshConnection, + remoteDir: string, + platform: RelayPlatform, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise { + // Linux is the only relay platform that takes forkpty()'s no-O_CLOEXEC path; macOS and Windows + // ship prebuilds, so forcing a rebuild there would add a first compile to fix nothing. + if (isWindowsRemoteHost(hostPlatform) || !platform.startsWith('linux')) { + return 'fixed' + } + try { + const command = commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1` + ) + const output = await execHostCommand(conn, hostPlatform, command, { + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) + const status = + output + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith(NODE_PTY_CLOEXEC_STATUS_PREFIX)) + ?.slice(NODE_PTY_CLOEXEC_STATUS_PREFIX.length) ?? 'no-status' + if (!NODE_PTY_CLOEXEC_FIXED_STATUSES.has(status)) { + // Warn, not log: the script exits 0 on a refusal too, so this line is the only thing that + // says the relay directory will leak a master into every child for its whole life. + console.warn( + `[ssh-relay][NPTY-CLOEXEC-UNFIXED] pty master still leaks at ${remoteDir} (${platform}): ${status}` + ) + return 'unfixed' + } + console.log(`[ssh-relay][NPTY-CLOEXEC] ${remoteDir} (${platform}): ${status}`) + return 'fixed' + } catch (err) { + signal?.throwIfAborted() + // Never fatal: the script restores the working build itself, and a leaky relay beats none. An + // interrupted rebuild leaves node-pty unloadable, which the existing repair path reinstalls. + console.warn( + `[ssh-relay][NPTY-CLOEXEC-FAIL] pty master cloexec patch failed at ${remoteDir} (${platform}): ${(err as Error).message}` + ) + // An exec that never answered cannot say which build is on disk, and a tree nobody can vouch + // for is exactly the one not to share. + return 'unfixed' + } +} + +/** + * Drop a shared-cache symlink before anything writes into `node_modules`. + * + * Why it prefixes every install rather than living in its own exec: `rm -rf node_modules/node-pty` + * and `npm install` both follow the link, so a repair on one relay directory would otherwise + * rewrite the tree every other relay on the host is running out of. + */ +function detachSharedNativeDepsCommand(hostPlatform: RemoteHostPlatform): string { + if (isWindowsRemoteHost(hostPlatform)) { + return '' + } + return 'if [ -L node_modules ]; then rm -f node_modules; fi; ' +} + function resetNativeDepsCommand( hostPlatform: RemoteHostPlatform, resetDeps: RelayNativeDepName[] @@ -1200,7 +1435,7 @@ async function installNativeDepsWithoutNodePty( hostPlatform, nodePath, remoteDir, - `${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` ), { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal } ) @@ -1337,7 +1572,8 @@ async function probeInstalledNativeDeps( } return { available: probeOutput.includes(PROBE_OK), - missing: probeOutput.includes(PROBE_OK) ? [] : missingNativeDepsFromProbe(probeOutput), + // A markerless answer names no dep, so it reports none; `available` already carries the failure. + missing: probeOutput.includes(PROBE_OK) ? [] : (missingNativeDepsFromProbe(probeOutput) ?? []), output: probeOutput, stderr: remoteStderr } @@ -1457,17 +1693,15 @@ async function launchRelay( } catch (err) { signal?.throwIfAborted() console.warn( - '[ssh-relay] Socket reconnect failed, launching fresh relay:', + '[ssh-relay] Socket reconnect failed, establishing what owns the endpoint:', err instanceof Error ? err.message : String(err) ) - // Why: stale socket from a crashed relay — remove it so the fresh launch can bind at the same path. - await execCommand(conn, `rm -f ${shellEscape(sockFile)}`, { signal }).catch( - (cleanupErr) => { - if (isUnconfirmedSshCommandTermination(cleanupErr)) { - throw cleanupErr - } - } - ) + // Why not `rm -f`: unlinking does not close the listener the incumbent already holds, + // so a refused --connect (version mismatch, rotated credential) used to leave a live + // relay running forever with its PTYs while a replacement bound the same path (#8585). + await resolveRelayEndpointBeforeRelaunch(conn, hostPlatform, nodePath, sockFile, err, { + signal + }) signal?.throwIfAborted() } } diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts new file mode 100644 index 00000000000..7ece0b6532e --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts @@ -0,0 +1,170 @@ +/** + * The probe and reap scripts run on someone else's machine and decide whether a process is + * signalled, so the shell itself is the part worth testing for real. These cases run the + * generated scripts through /bin/sh against real unix sockets and real processes. + */ +import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { + isReapableRelayHusk, + parseRelayEndpointIncumbentProbe, + relayEndpointIncumbentProbeCommand, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import { reapEmptyRelayHuskCommand } from './ssh-relay-endpoint-takeover' + +const posixOnly = process.platform === 'win32' ? describe.skip : describe + +const FAKE_RELAY_SOURCE = ` +const net = require('net') +const sock = process.argv[process.argv.indexOf('--sock-path') + 1] +if (process.argv.includes('--with-child')) { + require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore' + }) +} +net.createServer(() => {}).listen(sock, () => process.stdout.write('READY\\n')) +process.on('SIGTERM', () => process.exit(0)) +` + +function sh(script: string): Promise { + return new Promise((resolve, reject) => { + execFile('/bin/sh', ['-c', script], { timeout: 20_000 }, (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + }) + }) +} + +let workDir: string +let hasLsof = false +const running: ChildProcess[] = [] + +function startFakeRelay(sockPath: string, withChild = false): Promise { + const args = [join(workDir, 'relay.js'), '--sock-path', sockPath] + if (withChild) { + args.push('--with-child') + } + const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'ignore'] }) + running.push(child) + return new Promise((resolve, reject) => { + child.stdout.on('data', (chunk: Buffer) => { + if (chunk.toString().includes('READY')) { + resolve(child) + } + }) + child.on('exit', () => reject(new Error('fake relay exited before listening'))) + }) +} + +async function probe(sockPath: string): Promise { + const output = await sh(relayEndpointIncumbentProbeCommand(process.execPath, sockPath)) + return parseRelayEndpointIncumbentProbe(sockPath, output) +} + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), 'orca-relay-incumbent-')) + writeFileSync(join(workDir, 'relay.js'), FAKE_RELAY_SOURCE) + hasLsof = await sh('command -v lsof >/dev/null 2>&1 && echo yes || echo no').then( + (out) => out.trim() === 'yes' + ) +}) + +afterEach(() => { + while (running.length > 0) { + running.pop()?.kill('SIGKILL') + } +}) + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }) +}) + +it('runs the holder-enumeration assertions on this machine', () => { + // Why asserted rather than assumed: the cases below degrade to verdict-only checks without + // lsof, and a silently degraded suite would stop covering the reap gate entirely. + expect(hasLsof).toBe(true) +}) + +posixOnly('relay endpoint probe against a real socket', () => { + it('reports live, and identifies the holding process, for a listening relay', async () => { + const sockPath = join(workDir, 'live.sock') + const relay = await startFakeRelay(sockPath) + const incumbent = await probe(sockPath) + + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('accepted-connection') + expect(incumbent.socketPresent).toBe(true) + if (!hasLsof) { + return + } + expect(incumbent.holders.map((holder) => holder.pid)).toEqual([relay.pid]) + expect(incumbent.holders[0]).toMatchObject({ matchesRelayArgv: true, childCount: 0 }) + expect(isReapableRelayHusk(incumbent)).toBe(true) + }) + + it('refuses to call a relay with a live child an empty husk', async () => { + const sockPath = join(workDir, 'busy.sock') + await startFakeRelay(sockPath, true) + const incumbent = await probe(sockPath) + + expect(incumbent.verdict).toBe('live') + if (!hasLsof) { + return + } + expect(incumbent.holders[0].childCount).toBeGreaterThan(0) + expect(isReapableRelayHusk(incumbent)).toBe(false) + }) + + it('reports exited for a socket inode a SIGKILLed relay left behind', async () => { + const sockPath = join(workDir, 'stale.sock') + const relay = await startFakeRelay(sockPath) + relay.kill('SIGKILL') + await new Promise((resolve) => relay.on('exit', resolve)) + + const incumbent = await probe(sockPath) + expect(incumbent.socketPresent).toBe(true) + expect(incumbent.verdict).toBe(hasLsof ? 'exited' : 'unverifiable') + }) + + it('reports no listener for a path that was never bound', async () => { + const incumbent = await probe(join(workDir, 'never-existed.sock')) + expect(incumbent.socketPresent).toBe(false) + expect(incumbent.verdict).toBe(hasLsof ? 'exited' : 'unverifiable') + }) +}) + +posixOnly('empty relay husk reap against a real process', () => { + it('terminates a proven-empty relay and confirms the pid is gone', async () => { + const sockPath = join(workDir, 'husk.sock') + const relay = await startFakeRelay(sockPath) + const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('GONE') + }) + + it('refuses to signal a relay that acquired a child after it was probed', async () => { + const sockPath = join(workDir, 'raced.sock') + const relay = await startFakeRelay(sockPath, true) + const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('BUSY') + expect(relay.killed).toBe(false) + }) + + it('refuses to signal a pid whose argv is not this relay at this socket', async () => { + const sockPath = join(workDir, 'mismatch.sock') + await startFakeRelay(sockPath) + const bystander = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore' + }) + running.push(bystander) + const output = await sh(reapEmptyRelayHuskCommand(bystander.pid!, sockPath)) + expect(output.trim()).toBe('MISMATCH') + expect(bystander.killed).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts new file mode 100644 index 00000000000..a65cb33fc57 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from 'vitest' + +const execCommand = vi.fn() +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + isUnconfirmedSshCommandTermination: (error: unknown) => + (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false +})) + +import { + describeRelayEndpointIncumbent, + isReapableRelayHusk, + mayLaunchOverRelayEndpoint, + parseRelayEndpointIncumbentProbe, + probeRelayEndpointIncumbent, + relayEndpointIncumbentProbeCommand, + withHandshakeRefusalEvidence, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SOCK = '/home/u/.orca-remote/relay-0.1.0+aaaa/relay-deadbeef.sock' +const POSIX_HOST = getRemoteHostPlatform('linux-x64') +const WINDOWS_HOST = getRemoteHostPlatform('win32-x64') + +function probeOutput(lines: string[]): string { + return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') +} + +describe('parseRelayEndpointIncumbentProbe', () => { + it('reports live when the socket accepted a connection', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=4242 yes 13']) + ) + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('accepted-connection') + expect(incumbent.holders).toEqual([{ pid: 4242, matchesRelayArgv: true, childCount: 13 }]) + }) + + it('reports live when a process still holds an inode that refuses connections', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2']) + ) + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('holder-process') + }) + + it('reports exited only when the connect was refused AND nothing holds the socket', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) + ) + expect(incumbent.verdict).toBe('exited') + expect(incumbent.evidence).toBe('no-holder') + expect(incumbent.socketPresent).toBe(true) + }) + + it('reports unverifiable when the host cannot enumerate socket holders', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable']) + ) + expect(incumbent.verdict).toBe('unverifiable') + expect(incumbent.holdersEnumerable).toBe(false) + }) + + it('reports unverifiable when the connect probe timed out', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=lsof']) + ) + expect(incumbent.verdict).toBe('unverifiable') + }) + + it('reports unverifiable for truncated or garbled probe output', () => { + expect(parseRelayEndpointIncumbentProbe(SOCK, 'PRESENT=yes\nLISTEN=refused').verdict).toBe( + 'unverifiable' + ) + expect(parseRelayEndpointIncumbentProbe(SOCK, '').verdict).toBe('unverifiable') + }) + + it('drops holder lines that do not carry a usable pid', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=- no unknown']) + ) + expect(incumbent.holders).toEqual([]) + expect(incumbent.verdict).toBe('exited') + }) + + it('keeps an unreadable child count as null rather than zero', () => { + const [holder] = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes unknown']) + ).holders + expect(holder.childCount).toBeNull() + }) +}) + +describe('probeRelayEndpointIncumbent', () => { + it('never asserts death when the probe itself could not run', async () => { + execCommand.mockRejectedValueOnce(new Error('channel closed')) + const incumbent = await probeRelayEndpointIncumbent( + {} as SshConnection, + POSIX_HOST, + '/usr/bin/node', + SOCK + ) + expect(incumbent.verdict).toBe('unverifiable') + expect(incumbent.holders).toEqual([]) + }) + + it('does not shell out on Windows hosts, where the endpoint is a named pipe', async () => { + execCommand.mockClear() + const incumbent = await probeRelayEndpointIncumbent( + {} as SshConnection, + WINDOWS_HOST, + 'node.exe', + SOCK + ) + expect(execCommand).not.toHaveBeenCalled() + expect(incumbent.verdict).toBe('unverifiable') + }) +}) + +describe('relayEndpointIncumbentProbeCommand', () => { + it('ANDs the lsof selectors so it cannot match unrelated unix-socket holders', () => { + expect(relayEndpointIncumbentProbeCommand('/usr/bin/node', SOCK)).toContain( + 'lsof -t -a -U "$sock"' + ) + }) + + it('never mutates the host: no unlink, no signal', () => { + const command = relayEndpointIncumbentProbeCommand('/usr/bin/node', SOCK) + expect(command).not.toMatch(/\brm\b/) + expect(command).not.toMatch(/\bkill\b/) + }) +}) + +describe('withHandshakeRefusalEvidence', () => { + it('upgrades an unenumerable endpoint to live when the daemon answered the handshake', () => { + const probed = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + const incumbent = withHandshakeRefusalEvidence(probed) + expect(incumbent.verdict).toBe('live') + expect(incumbent.evidence).toBe('handshake-refusal') + expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(false) + }) + + it('leaves stronger evidence in place', () => { + const probed = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof']) + ) + expect(withHandshakeRefusalEvidence(probed).evidence).toBe('accepted-connection') + }) +}) + +describe('mayLaunchOverRelayEndpoint', () => { + const verdicts: RelayEndpointIncumbent['verdict'][] = ['live', 'unverifiable', 'exited'] + it.each(verdicts)('permits a relaunch for %s only when it is not live', (verdict) => { + const incumbent = { ...parseRelayEndpointIncumbentProbe(SOCK, ''), verdict } + expect(mayLaunchOverRelayEndpoint(incumbent)).toBe(verdict !== 'live') + }) +}) + +describe('isReapableRelayHusk', () => { + const husk = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0']) + ) + + it('accepts a single proven relay holder with zero children', () => { + expect(isReapableRelayHusk(husk)).toBe(true) + }) + + it('refuses a relay that still holds children', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 1 }] + }) + ).toBe(false) + }) + + it('refuses a holder whose child count could not be read', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: true, childCount: null }] + }) + ).toBe(false) + }) + + it('refuses a holder whose argv is not this relay at this socket', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0 }] + }) + ).toBe(false) + }) + + it('refuses when more than one process holds the socket', () => { + expect( + isReapableRelayHusk({ + ...husk, + holders: [ + { pid: 500, matchesRelayArgv: true, childCount: 0 }, + { pid: 501, matchesRelayArgv: true, childCount: 0 } + ] + }) + ).toBe(false) + }) + + it('refuses an unverifiable endpoint however empty it looks', () => { + expect(isReapableRelayHusk({ ...husk, verdict: 'unverifiable' })).toBe(false) + expect(isReapableRelayHusk({ ...husk, holdersEnumerable: false })).toBe(false) + }) +}) + +describe('describeRelayEndpointIncumbent', () => { + it('distinguishes "no holders" from "could not enumerate holders"', () => { + const none = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) + ) + const unknown = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=unavailable']) + ) + expect(describeRelayEndpointIncumbent(none)).toContain('holders=none') + expect(describeRelayEndpointIncumbent(unknown)).toContain('holders=unenumerable') + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts new file mode 100644 index 00000000000..2688267f4c7 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -0,0 +1,285 @@ +/** + * Who currently owns a relay socket path, answered with host evidence. + * + * The client used to answer this by assumption: a failed `--connect` was read as "the relay + * crashed", the socket was `rm -f`'d, and a fresh relay bound the same path. Unlinking a unix + * socket does not close the listener the incumbent already holds, so an alive-but-refusing + * relay (the `RelayVersionMismatchError` case, and the credential-rotation case) was left + * running forever with its PTYs (#8585). + * + * The verdict vocabulary is fixed by docs/reference/ssh-execution-boundary.md — `live` / + * `unverifiable` / `exited`, with no synonyms and no collapsing. Two consequences are load + * bearing here: + * + * - `exited` is a claim about **this endpoint**, not about every relay on the host. It means + * nothing holds this socket path, established positively (a connect that was refused *and* + * an enumeration that found no holder). A relay whose socket was already unlinked is + * invisible to this probe by construction — that is what the superseded sweep is for. + * - a probe that could not run, a host without `lsof`, or a connect that failed for any other + * reason is `unverifiable`. It never authorizes unlinking, rebinding over, or signalling. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export type RelayEndpointVerdict = 'live' | 'unverifiable' | 'exited' + +export type RelayEndpointEvidence = + | 'accepted-connection' + | 'handshake-refusal' + | 'holder-process' + | 'no-holder' + | 'inconclusive' + +export type RelayEndpointHolder = { + pid: number + /** The holder's argv names relay.js AND this exact socket path. */ + matchesRelayArgv: boolean + /** Direct children, or null when `pgrep` could not answer. Never guessed. */ + childCount: number | null +} + +export type RelayEndpointIncumbent = { + sockPath: string + verdict: RelayEndpointVerdict + evidence: RelayEndpointEvidence + socketPresent: boolean + /** Pids proven to hold this exact socket. Empty when the host could not enumerate them. */ + holders: RelayEndpointHolder[] + /** False when no enumeration tool was available — an empty `holders` then proves nothing. */ + holdersEnumerable: boolean +} + +const PROBE_BEGIN = 'ORCA-INCUMBENT-BEGIN' +const PROBE_END = 'ORCA-INCUMBENT-END' +const CONNECT_PROBE_TIMEOUT_MS = 1000 + +// Why ES5 syntax: nodePath may be a host-resolved system node, not the bundled one. +const CONNECT_PROBE_JS = [ + 'var s=require("net").connect(process.argv[1]);', + 'var done=false;', + 'function say(v){if(done)return;done=true;try{s.destroy()}catch(e){};', + 'process.stdout.write(v);process.exit(0)}', + 's.on("connect",function(){say("accepted")});', + 's.on("error",function(e){', + 'say(e.code==="ECONNREFUSED"?"refused":e.code==="ENOENT"?"absent":"unknown")});', + `setTimeout(function(){say("unknown")},${CONNECT_PROBE_TIMEOUT_MS})` +].join('') + +/** + * A POSIX probe that reports only what the host actually observed. Every field has an + * explicit "could not tell" value; nothing is inferred from a missing tool. + */ +export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: string): string { + const sock = shellEscape(sockPath) + const node = shellEscape(nodePath) + return [ + `sock=${sock}`, + `node=${node}`, + `printf '%s\\n' ${shellEscape(PROBE_BEGIN)}`, + 'if [ -S "$sock" ]; then', + " printf 'PRESENT=yes\\n'", + ` listen=$("$node" -e ${shellEscape(CONNECT_PROBE_JS)} "$sock" 2>/dev/null) || listen=unknown`, + ' [ -n "$listen" ] || listen=unknown', + 'else', + " printf 'PRESENT=no\\n'", + ' listen=absent', + 'fi', + 'printf \'LISTEN=%s\\n\' "$listen"', + 'if command -v lsof >/dev/null 2>&1; then', + " printf 'HOLDERS_SOURCE=lsof\\n'", + // Why -a: lsof ORs its selectors, so without it every unix-socket holder on the box + // would be reported as holding this path (#8762). + ' for pid in $(lsof -t -a -U "$sock" 2>/dev/null); do', + ' args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', + ' match=no', + ' case "$args" in *relay.js*"$sock"*) match=yes ;; esac', + ' kids=unknown', + ' if command -v pgrep >/dev/null 2>&1; then', + ' kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', + ' fi', + ' printf \'HOLDER=%s %s %s\\n\' "$pid" "$match" "$kids"', + ' done', + 'else', + " printf 'HOLDERS_SOURCE=unavailable\\n'", + 'fi', + `printf '%s\\n' ${shellEscape(PROBE_END)}` + ].join('\n') +} + +export function parseRelayEndpointIncumbentProbe( + sockPath: string, + output: string +): RelayEndpointIncumbent { + const lines = output.split('\n').map((line) => line.trim()) + if (!lines.includes(PROBE_BEGIN) || !lines.includes(PROBE_END)) { + return unverifiableEndpoint(sockPath) + } + const socketPresent = lines.includes('PRESENT=yes') + const listen = lines.find((line) => line.startsWith('LISTEN='))?.slice('LISTEN='.length) ?? '' + const holdersEnumerable = lines.includes('HOLDERS_SOURCE=lsof') + const holders = lines + .filter((line) => line.startsWith('HOLDER=')) + .map((line) => parseHolder(line.slice('HOLDER='.length))) + .filter((holder): holder is RelayEndpointHolder => holder !== null) + + if (listen === 'accepted') { + return { + sockPath, + verdict: 'live', + evidence: 'accepted-connection', + socketPresent, + holders, + holdersEnumerable + } + } + if (holders.length > 0) { + // The inode is held by a running process that is not accepting — wedged, not gone. + return { + sockPath, + verdict: 'live', + evidence: 'holder-process', + socketPresent, + holders, + holdersEnumerable + } + } + if (holdersEnumerable && (listen === 'refused' || listen === 'absent')) { + return { + sockPath, + verdict: 'exited', + evidence: 'no-holder', + socketPresent, + holders, + holdersEnumerable + } + } + return { ...unverifiableEndpoint(sockPath), socketPresent, holders, holdersEnumerable } +} + +function parseHolder(value: string): RelayEndpointHolder | null { + const [rawPid, rawMatch, rawKids] = value.split(/\s+/) + const pid = Number.parseInt(rawPid ?? '', 10) + if (!Number.isInteger(pid) || pid <= 0) { + return null + } + const childCount = Number.parseInt(rawKids ?? '', 10) + return { + pid, + matchesRelayArgv: rawMatch === 'yes', + childCount: Number.isInteger(childCount) && childCount >= 0 ? childCount : null + } +} + +function unverifiableEndpoint(sockPath: string): RelayEndpointIncumbent { + return { + sockPath, + verdict: 'unverifiable', + evidence: 'inconclusive', + socketPresent: false, + holders: [], + holdersEnumerable: false + } +} + +export async function probeRelayEndpointIncumbent( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + nodePath: string, + sockPath: string, + options?: { signal?: AbortSignal } +): Promise { + // Windows relays are named pipes: there is no inode to unlink and no `lsof`, so the + // orphan-by-unlink mechanism this probe defends against cannot occur there. + if (isWindowsRemoteHost(hostPlatform)) { + return unverifiableEndpoint(sockPath) + } + try { + const output = await execCommand(conn, relayEndpointIncumbentProbeCommand(nodePath, sockPath), { + wrapCommand: true, + signal: options?.signal + }) + return parseRelayEndpointIncumbentProbe(sockPath, output) + } catch (err) { + // An exec whose channel never confirmed close may still be running remotely; the caller + // must not race a detached launch against it. + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + // Any other unanswered probe observes nothing. It is never evidence of death. + return unverifiableEndpoint(sockPath) + } +} + +/** + * A relay that told us its version over the wire is `live` by positive host evidence, even on + * a host where nothing can enumerate socket holders. + */ +export function withHandshakeRefusalEvidence( + incumbent: RelayEndpointIncumbent +): RelayEndpointIncumbent { + if (incumbent.verdict === 'live') { + return incumbent + } + return { ...incumbent, verdict: 'live', evidence: 'handshake-refusal' } +} + +/** + * May a fresh relay be launched onto this path? + * + * Only `live` forbids it. `unverifiable` is permitted because the *daemon* — not the client — + * performs the takeover: `RelaySocketOwnership.listen` re-probes on EADDRINUSE, refuses to + * steal a path that accepts connections, and only unlinks an inode whose identity is + * unchanged. That check is atomic with the bind, which a client-side `rm -f` can never be. + */ +export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): boolean { + return incumbent.verdict !== 'live' +} + +/** + * A live relay that provably holds nothing: identity confirmed against its argv, exactly one + * holder, and zero children. Reaping it destroys no user work. Anything less is retained — + * killing the wrong pid on someone's remote host is the worst outcome available here. + */ +export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean { + if (incumbent.verdict !== 'live' || !incumbent.holdersEnumerable) { + return false + } + if (incumbent.holders.length !== 1) { + return false + } + const [holder] = incumbent.holders + return holder.matchesRelayArgv && holder.childCount === 0 +} + +export function describeRelayEndpointIncumbent(incumbent: RelayEndpointIncumbent): string { + const holders = incumbent.holders + .map((holder) => `${holder.pid}(children=${holder.childCount ?? 'unknown'})`) + .join(',') + return ( + `${incumbent.sockPath} verdict=${incumbent.verdict} evidence=${incumbent.evidence} ` + + `holders=${incumbent.holdersEnumerable ? holders || 'none' : 'unenumerable'}` + ) +} + +/** + * Thrown instead of orphaning: a live relay owns the endpoint and refused us, so the path is + * not ours to rebind. Terminal for this attempt — the user resolves it with Reset Relay, + * which signals the incumbent deliberately and with consent. + */ +export class RelayEndpointHeldError extends Error { + readonly name = 'RelayEndpointHeldError' + constructor(readonly incumbent: RelayEndpointIncumbent) { + super( + `A live relay still owns ${incumbent.sockPath} and refused this connection ` + + `(${describeRelayEndpointIncumbent(incumbent)}). Orca will not replace it, because ` + + 'unlinking its socket would strand its terminals. Use Reset Relay for this host to ' + + 'stop it, then reconnect.' + ) + } +} + +export function isRelayEndpointHeldError(err: unknown): err is RelayEndpointHeldError { + return err instanceof RelayEndpointHeldError +} diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts new file mode 100644 index 00000000000..687d633b92b --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const execCommand = vi.fn() +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + isUnconfirmedSshCommandTermination: (error: unknown) => + (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false +})) + +import { isRelayEndpointHeldError } from './ssh-relay-endpoint-incumbent' +import { + interpretRelayHuskReapOutput, + reapEmptyRelayHuskCommand, + resolveRelayEndpointBeforeRelaunch +} from './ssh-relay-endpoint-takeover' +import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SOCK = '/home/u/.orca-remote/relay-0.1.0+aaaa/relay-deadbeef.sock' +const HOST = getRemoteHostPlatform('linux-x64') +const CONN = {} as SshConnection + +function probe(lines: string[]): string { + return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') +} + +function issuedCommands(): string[] { + return execCommand.mock.calls.map((call) => String(call[1])) +} + +function resolve(reconnectError: unknown = new Error('connect failed')): Promise { + return resolveRelayEndpointBeforeRelaunch(CONN, HOST, '/usr/bin/node', SOCK, reconnectError) +} + +beforeEach(() => { + execCommand.mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +describe('incumbent alive and refusing', () => { + it('refuses to rebind a live relay holding PTYs, and signals nothing', async () => { + execCommand.mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + ) + await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) + // The whole point of #8585: the incumbent's socket must survive so it is not orphaned. + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + expect(issuedCommands().some((command) => /\bkill\b/.test(command))).toBe(false) + }) + + it('names the incumbent pid and the Reset Relay escape hatch in the error', async () => { + execCommand.mockResolvedValue( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + ) + await expect(resolve()).rejects.toThrow(/3669803\(children=13\)/) + await expect(resolve()).rejects.toThrow(/Reset Relay/) + }) + + it('treats a version mismatch as live even where holders cannot be enumerated', async () => { + execCommand.mockResolvedValue( + probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + const mismatch = new RelayVersionMismatchError('0.1.0+new', '0.1.0+old', '') + await expect(resolve(mismatch)).rejects.toSatisfy(isRelayEndpointHeldError) + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + }) + + it('reaps a live relay only when it provably holds nothing, and confirms it is gone', async () => { + execCommand + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('GONE\n') + await expect(resolve()).resolves.toMatchObject({ verdict: 'live' }) + expect(issuedCommands()[1]).toContain('kill -TERM "$pid"') + }) + + it('does not launch over an empty relay whose death could not be confirmed', async () => { + execCommand + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('LIVE\n') + await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) + }) + + it('does not launch over a relay the host refused to signal on its own re-check', async () => { + execCommand + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('BUSY\n') + await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) + }) +}) + +describe('incumbent genuinely gone', () => { + it('permits the relaunch without unlinking anything itself', async () => { + execCommand.mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof']) + ) + await expect(resolve()).resolves.toMatchObject({ verdict: 'exited', evidence: 'no-holder' }) + // The daemon unlinks under an identity check that is atomic with its bind; the client + // cannot be, which is what created the orphan in the first place. + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + }) +}) + +describe('incumbent unverifiable', () => { + it('permits the relaunch but never claims the incumbent exited', async () => { + execCommand.mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + await expect(resolve()).resolves.toMatchObject({ verdict: 'unverifiable' }) + expect(issuedCommands()).toHaveLength(1) + }) + + it('stays unverifiable when the probe command itself fails', async () => { + execCommand.mockRejectedValueOnce(new Error('exec timeout')) + await expect(resolve()).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) +}) + +describe('reapEmptyRelayHuskCommand', () => { + it('re-verifies argv and emptiness on the host immediately before signalling', () => { + const command = reapEmptyRelayHuskCommand(4242, SOCK) + expect(command.indexOf('MISMATCH')).toBeLessThan(command.indexOf('kill -TERM')) + expect(command.indexOf('BUSY')).toBeLessThan(command.indexOf('kill -TERM')) + }) + + it('sends SIGTERM only, so the relay runs its own socket cleanup', () => { + const command = reapEmptyRelayHuskCommand(4242, SOCK) + expect(command).toContain('kill -TERM') + expect(command).not.toContain('kill -KILL') + expect(command).not.toContain('-9') + }) + + it('aborts without signalling when the host cannot count children', () => { + expect(reapEmptyRelayHuskCommand(4242, SOCK)).toContain( + "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }" + ) + }) +}) + +describe('interpretRelayHuskReapOutput', () => { + it('claims reaped only for a post-signal liveness check that failed', () => { + expect(interpretRelayHuskReapOutput('GONE\n')).toBe('reaped') + expect(interpretRelayHuskReapOutput('LIVE\n')).toBe('reap-unconfirmed') + expect(interpretRelayHuskReapOutput('')).toBe('reap-unconfirmed') + expect(interpretRelayHuskReapOutput('unexpected noise')).toBe('reap-unconfirmed') + }) + + it('reports a host-side refusal as retained rather than as a failed kill', () => { + expect(interpretRelayHuskReapOutput('MISMATCH\n')).toBe('retained-live-work') + expect(interpretRelayHuskReapOutput('BUSY\n')).toBe('retained-live-work') + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.ts b/src/main/ssh/ssh-relay-endpoint-takeover.ts new file mode 100644 index 00000000000..f104aab5256 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-takeover.ts @@ -0,0 +1,133 @@ +/** + * Deciding whether a relay socket path is ours to take, and acting on the answer. + * + * The only destructive action available here is a SIGTERM to a relay that has been proven — + * by argv, by socket-holder enumeration, and by a zero child count re-checked on the host + * immediately before the signal — to hold nothing at all. Everything else is left running. + * Per docs/reference/ssh-execution-boundary.md, a relay we merely failed to reach is + * `unverifiable`, and `unverifiable` never authorizes a kill or a rebind. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { + describeRelayEndpointIncumbent, + isReapableRelayHusk, + mayLaunchOverRelayEndpoint, + probeRelayEndpointIncumbent, + RelayEndpointHeldError, + withHandshakeRefusalEvidence, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +/** `reaped` is only reachable from a post-signal `kill -0` that failed. Nothing else claims it. */ +export type RelayHuskReapResult = 'reaped' | 'reap-unconfirmed' | 'retained-live-work' + +const REAP_CONFIRM_ATTEMPTS = 15 + +/** + * Signal one relay, re-verifying identity and emptiness inside the same command. + * + * The re-verification is not belt-and-braces: a client can attach and spawn a PTY between the + * probe and the signal, and pids are reused. `MISMATCH`/`BUSY` abort without signalling. + */ +export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string { + return [ + `pid=${shellEscape(String(pid))}`, + `sock=${shellEscape(sockPath)}`, + 'args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', + 'case "$args" in *relay.js*"$sock"*) ;; *) printf \'MISMATCH\\n\'; exit 0 ;; esac', + "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }", + 'kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', + '[ "$kids" = "0" ] || { printf \'BUSY\\n\'; exit 0; }', + // SIGTERM only: the relay's own handler disposes and unlinks. SIGKILL would leave the + // socket inode behind and skip that shutdown path for no gain on an empty daemon. + 'kill -TERM "$pid" 2>/dev/null || true', + 'i=0', + `while [ $i -lt ${REAP_CONFIRM_ATTEMPTS} ]; do`, + ' kill -0 "$pid" 2>/dev/null || { printf \'GONE\\n\'; exit 0; }', + ' sleep 0.2', + ' i=$((i+1))', + 'done', + "printf 'LIVE\\n'" + ].join('\n') +} + +export function interpretRelayHuskReapOutput(output: string): RelayHuskReapResult { + const state = output.trim().split('\n').pop()?.trim() + if (state === 'GONE') { + return 'reaped' + } + // The host refused on its own re-check: what is there is not the empty relay we probed, so + // nothing was signalled and nothing is claimed about it. + if (state === 'MISMATCH' || state === 'BUSY') { + return 'retained-live-work' + } + return 'reap-unconfirmed' +} + +export async function reapEmptyRelayHusk( + conn: SshConnection, + incumbent: RelayEndpointIncumbent, + options?: { signal?: AbortSignal } +): Promise { + const holder = incumbent.holders[0] + if (!holder) { + return 'retained-live-work' + } + try { + const output = await execCommand( + conn, + reapEmptyRelayHuskCommand(holder.pid, incumbent.sockPath), + { wrapCommand: true, signal: options?.signal } + ) + return interpretRelayHuskReapOutput(output) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + return 'reap-unconfirmed' + } +} + +/** + * Called when `--connect` to an existing socket failed and the caller is about to launch a + * replacement at the same path. Resolves to nothing when the launch may proceed; throws + * `RelayEndpointHeldError` when a live relay owns the path and holds work. + * + * `unverifiable` deliberately permits the launch: the daemon, not the client, performs the + * takeover. `RelaySocketOwnership.listen` re-probes on EADDRINUSE, refuses a path that accepts + * connections, and only unlinks an inode whose identity is unchanged — a check that is atomic + * with the bind, which a client-side `rm -f` can never be. + */ +export async function resolveRelayEndpointBeforeRelaunch( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + nodePath: string, + sockPath: string, + reconnectError: unknown, + options?: { signal?: AbortSignal } +): Promise { + const probed = await probeRelayEndpointIncumbent(conn, hostPlatform, nodePath, sockPath, options) + // A daemon that answered the handshake with its own version is live by positive host + // evidence, even where nothing can enumerate socket holders. + const incumbent = isRelayVersionMismatchError(reconnectError) + ? withHandshakeRefusalEvidence(probed) + : probed + console.warn(`[ssh-relay] Relay endpoint incumbent: ${describeRelayEndpointIncumbent(incumbent)}`) + + if (mayLaunchOverRelayEndpoint(incumbent)) { + return incumbent + } + if (!isReapableRelayHusk(incumbent)) { + throw new RelayEndpointHeldError(incumbent) + } + const result = await reapEmptyRelayHusk(conn, incumbent, options) + if (result !== 'reaped') { + throw new RelayEndpointHeldError(incumbent) + } + console.log(`[ssh-relay] Reaped empty relay husk holding ${sockPath}`) + return incumbent +} diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index c631cd2d41a..fb0a4fee012 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -13,6 +13,11 @@ const MAX_EXEC_OUTPUT_CHARS = 1024 * 1024 type ExecCommandOptions = SshExecOptions & { timeoutMs?: number + // Why: a zero-exit command resolves with stdout alone, so the reason a wrapped-in-`|| echo` + // probe failed is discarded. Callers that need that diagnostic opt in here rather than + // folding stderr into stdout, where it would match the probe's own token strings. + // On the system-ssh transport this stream also carries local OpenSSH noise; log-only. + onStderr?: (stderr: string) => void } type SshCommandTerminationError = Error & { @@ -33,7 +38,7 @@ export async function execCommand( command: string, options?: ExecCommandOptions ): Promise { - const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const { timeoutMs = EXEC_TIMEOUT_MS, onStderr, ...execOptions } = options ?? {} const signal = options?.signal if (signal?.aborted) { throw createSshOperationAbortError() @@ -151,6 +156,9 @@ export async function execCommand( ) ) } else { + if (stderr && onStderr) { + onStderr(redactRelayInstallMarkerTokens(stderr)) + } settle(resolve, stdout) } } diff --git a/src/main/ssh/ssh-relay-native-deps-cache-commands.ts b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts new file mode 100644 index 00000000000..52d09de1b19 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-commands.ts @@ -0,0 +1,210 @@ +/** + * The remote shell for the shared native-deps cache (`ssh-relay-native-deps-cache.ts`). + * + * Every script here is POSIX `sh` and answers with one token, because the only alternative to a + * token is inferring success from an exit status the transport can also produce. A command that + * cannot answer is a cache miss, never a licence to delete: `MISS` and `NOT_PROMOTED` both leave + * the relay directory owning its own `node_modules`, which is exactly today's behaviour. + */ +import { shellEscape } from './ssh-connection-utils' +import { + RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME, + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX, + relayNativeDepsCacheBaseDir, + relayNativeDepsCacheEntryDir, + relayNativeDepsCacheNodeModulesPath, + remoteInstallRootDir +} from './ssh-relay-native-deps-cache' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export const RELAY_NATIVE_CACHE_LINKED = '__ORCA_NATIVE_CACHE__LINKED' +export const RELAY_NATIVE_CACHE_SEEDED = '__ORCA_NATIVE_CACHE__SEEDED' +export const RELAY_NATIVE_CACHE_MISS = '__ORCA_NATIVE_CACHE__MISS' +export const RELAY_NATIVE_CACHE_PROMOTED = '__ORCA_NATIVE_CACHE__PROMOTED' +export const RELAY_NATIVE_CACHE_NOT_PROMOTED = '__ORCA_NATIVE_CACHE__NOT_PROMOTED' +export const RELAY_NATIVE_CACHE_LIST_OK = '__ORCA_NATIVE_CACHE__LIST_OK' +export const RELAY_NATIVE_CACHE_REFS_OK = '__ORCA_NATIVE_CACHE__REFS_OK' +export const RELAY_NATIVE_CACHE_REFS_ERR = '__ORCA_NATIVE_CACHE__REFS_ERR' + +/** + * How old an entry without `.deps-complete` must be before another deploy may reclaim it. Well + * past the 15-minute deploy ceiling, so a live installer is never mistaken for a crashed one. + * Reclaiming is safe at any age in principle — nothing links an entry until it is complete — but + * the margin is what keeps that argument from resting on a single `[ -f ]`. + */ +const CACHE_TAKEOVER_MINUTES = 120 + +/** A crashed GC pass leaves a tombstone; it drains once no in-flight pass could still own it. */ +const CACHE_TOMBSTONE_SWEEP_MINUTES = 30 + +/** Bounds every listing, matching `MAX_RELAY_GC_LISTING_ENTRIES`' role for version dirs. */ +export const MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES = 64 + +export type RelayNativeDepsCachePaths = { + host: RemoteHostPlatform + remoteHome: string + relayDir: string + key: string +} + +function cachePaths(paths: RelayNativeDepsCachePaths): { + base: string + entry: string + target: string + nodeModules: string + root: string +} { + const { host, remoteHome, relayDir, key } = paths + return { + base: relayNativeDepsCacheBaseDir(host, remoteHome), + entry: relayNativeDepsCacheEntryDir(host, remoteHome, key), + target: relayNativeDepsCacheNodeModulesPath(host, remoteHome, key), + nodeModules: joinRemotePath(host, relayDir, 'node_modules'), + root: remoteInstallRootDir(host, remoteHome) + } +} + +/** + * Link a complete entry into the relay directory, or seed a private tree from a sibling relay + * directory that already has a matching one. + * + * The seed exists so the first deploy after this ships does not recompile once more on a host + * that already paid for the compile. It is not trusted: the copy is a plain private install until + * the normal probe loads both addons, and only then is it promoted. + */ +export function ensureRelayNativeDepsCacheCommand( + paths: RelayNativeDepsCachePaths, + deps: Readonly> +): string { + const { entry, target, nodeModules, root } = cachePaths(paths) + // Why grep the sibling's manifest: an older Orca pinned different versions, and a + // toolchain-skip host wrote one with node-pty removed. Both must fail to qualify. + const depGuards = Object.entries(deps).map( + ([name, version]) => `grep -F -q ${shellEscape(`"${name}":"${version}"`)} "$pj" || continue` + ) + return [ + `cache=${shellEscape(entry)}`, + `target=${shellEscape(target)}`, + `nm=${shellEscape(nodeModules)}`, + `root=${shellEscape(root)}`, + `if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] && [ -d "$target" ]; then`, + ' if [ -L "$nm" ]; then', + ` if [ "$(readlink "$nm" 2>/dev/null)" = "$target" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, + ' rm -f "$nm" 2>/dev/null || true', + ' fi', + ` if [ ! -e "$nm" ] && ln -s "$target" "$nm" 2>/dev/null; then printf '%s\\n' ${RELAY_NATIVE_CACHE_LINKED}; exit 0; fi`, + ` printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}; exit 0`, + 'fi', + 'if [ ! -e "$nm" ] && [ ! -L "$nm" ]; then', + ' for cand in "$root"/relay-*/node_modules; do', + ' [ -d "$cand" ] || continue', + ' [ -L "$cand" ] && continue', + ' [ -d "$cand/node-pty" ] || continue', + ' [ -d "$cand/@parcel/watcher" ] || continue', + ' pj="${cand%/node_modules}/package.json"', + ' [ -f "$pj" ] || continue', + ...depGuards.map((guard) => ` ${guard}`), + ' seed="$nm.seed.$$"', + ' rm -rf "$seed" 2>/dev/null || true', + ' if cp -Rp "$cand" "$seed" 2>/dev/null && mv "$seed" "$nm" 2>/dev/null; then', + ` printf '%s\\n' ${RELAY_NATIVE_CACHE_SEEDED}; exit 0`, + ' fi', + ' rm -rf "$seed" 2>/dev/null || true', + ' break', + ' done', + 'fi', + `printf '%s\\n' ${RELAY_NATIVE_CACHE_MISS}` + ].join('\n') +} + +/** + * Publish a probe-verified private tree as the shared entry, then link the relay directory to it. + * + * `mkdir "$cache"` is the election: exactly one deploy creates the directory, and a loser keeps + * its own tree rather than writing into someone else's. `.deps-complete` is written last, after + * the symlink exists, so an entry is never linkable before it is referenced. + */ +export function promoteRelayNativeDepsCacheCommand(paths: RelayNativeDepsCachePaths): string { + const { base, entry, target, nodeModules } = cachePaths(paths) + const notPromoted = `printf '%s\\n' ${RELAY_NATIVE_CACHE_NOT_PROMOTED}` + return [ + `base=${shellEscape(base)}`, + `cache=${shellEscape(entry)}`, + `target=${shellEscape(target)}`, + `nm=${shellEscape(nodeModules)}`, + `[ -d "$nm" ] || { ${notPromoted}; exit 0; }`, + `if [ -L "$nm" ]; then ${notPromoted}; exit 0; fi`, + `mkdir -p "$base" 2>/dev/null || { ${notPromoted}; exit 0; }`, + `if [ -d "$cache" ] && [ ! -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then`, + ` if [ -n "$(find "$cache" -maxdepth 0 -mmin +${CACHE_TAKEOVER_MINUTES} 2>/dev/null)" ]; then`, + ' rm -rf "$cache" 2>/dev/null || true', + ' fi', + 'fi', + `mkdir "$cache" 2>/dev/null || { ${notPromoted}; exit 0; }`, + 'if mv "$nm" "$target" 2>/dev/null; then', + ' if ln -s "$target" "$nm" 2>/dev/null; then', + ` : > "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" 2>/dev/null || true`, + ` if [ -f "$cache/${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_PROMOTED}; exit 0; fi`, + ' fi', + ' rm -f "$nm" 2>/dev/null || true', + // Why the rm is conditional on the move back: a failed restore leaves the only copy of the + // tree inside an incomplete entry. Deleting it there would cost the relay its native deps. + ' if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache" 2>/dev/null || true; fi', + ` ${notPromoted}; exit 0`, + 'fi', + 'rm -rf "$cache" 2>/dev/null || true', + notPromoted + ].join('\n') +} + +/** Complete entries only; an incomplete one belongs to an installer, not to GC. */ +export function listRelayNativeDepsCacheEntriesCommand( + host: RemoteHostPlatform, + remoteHome: string +): string { + const base = relayNativeDepsCacheBaseDir(host, remoteHome) + return [ + `base=${shellEscape(base)}`, + `[ -d "$base" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}; exit 0; }`, + `find "$base" -maxdepth 1 -name ${shellEscape(`${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}*`)} -mmin +${CACHE_TOMBSTONE_SWEEP_MINUTES} -exec rm -rf {} + 2>/dev/null || true`, + 'n=0', + 'for d in "$base"/*/; do', + ' [ -d "$d" ] || continue', + ` [ -f "$d${RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME}" ] || continue`, + ' name=${d%/}', + ' name=${name##*/}', + ` printf 'ENTRY %s\\n' "$name"`, + ' n=$((n+1))', + ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then break; fi`, + 'done', + `printf '%s\\n' ${RELAY_NATIVE_CACHE_LIST_OK}` + ].join('\n') +} + +/** + * Every symlinked `node_modules` under `~/.orca-remote/`, as its raw target. + * + * The scan is deliberately wider than `relay-*`: a directory this client does not recognise still + * counts as a referrer. An unreadable link or an overrun listing answers `REFS_ERR`, which stops + * the whole pass — an incomplete reference list is not evidence that anything is unreferenced. + */ +export function listRelayNativeDepsCacheReferencesCommand( + host: RemoteHostPlatform, + remoteHome: string +): string { + const root = remoteInstallRootDir(host, remoteHome) + return [ + `root=${shellEscape(root)}`, + `[ -d "$root" ] || { printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}; exit 0; }`, + 'n=0', + 'for d in "$root"/*/node_modules; do', + ' [ -L "$d" ] || continue', + ' t=$(readlink "$d" 2>/dev/null) || t=""', + ` if [ -z "$t" ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, + ` printf 'REF %s\\n' "$t"`, + ' n=$((n+1))', + ` if [ "$n" -ge ${MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES} ]; then printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_ERR}; exit 0; fi`, + 'done', + `printf '%s\\n' ${RELAY_NATIVE_CACHE_REFS_OK}` + ].join('\n') +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts new file mode 100644 index 00000000000..cafc82960f3 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-deploy.test.ts @@ -0,0 +1,308 @@ +// The claim this file has to hold up: a second deploy of a *different bundle* to the same host +// runs no `npm install` at all. Everything else here is the fallback ladder underneath it — a +// host that cannot link, cannot publish, or answers nothing still deploys exactly as before. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { isRelayAlreadyInstalled, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { + makeMockConnection, + makeStagedFirstInstallExecPrefix, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' +import { + RELAY_NATIVE_CACHE_LINKED, + RELAY_NATIVE_CACHE_MISS, + RELAY_NATIVE_CACHE_PROMOTED +} from './ssh-relay-native-deps-cache-commands' + +// Everything after the probe on a healthy install: stderr cleanup, stage cleanup, launch. +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' +const LAUNCH_TAIL: ExecResponse[] = ['', 'DEAD', '', 'READY'] + +describe('relay native-deps cache on the deploy path', () => { + let warnSpy: ReturnType + const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const k of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[k] + } + for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[k] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + function feed(responses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const r of responses) { + if (typeof r === 'string') { + mockExec.mockResolvedValueOnce(r) + } else { + mockExec.mockRejectedValueOnce(new Error(r.reject)) + } + } + } + + function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => command) + } + + /** + * A first install whose cache probe answers `cacheAnswer`. The prefix's last slot is the cache + * probe, so overriding it is the only difference between a hit and a miss. + */ + function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { + const prefix = makeStagedFirstInstallExecPrefix() + prefix[prefix.length - 1] = cacheAnswer + return [...prefix, ...tail] + } + + it('runs no npm install when a different bundle finds a complete entry on the host', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds, through the symlink + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const commands = execCommands() + expect(commands.some((c) => c.includes('npm install'))).toBe(false) + expect(commands.some((c) => c.includes('npm rebuild'))).toBe(false) + // The bundle still gets its own directory; only the native tree is shared. + expect(commands.some((c) => c.includes('.orca-remote/relay-0.1.0+testhash'))).toBe(true) + expect(commands.some((c) => /\.orca-remote\/native\/linux-x64-[0-9a-f]{16}/.test(c))).toBe(true) + }) + + it('still installs on the first deploy, then publishes the tree the probe loaded', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_MISS, [ + '', // npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + RELAY_NATIVE_CACHE_PROMOTED, + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const commands = execCommands() + const install = commands.find((c) => c.includes('npm install')) ?? '' + expect(install).toContain('node-pty@1.1.0') + // The install runs in the relay directory; publication moves the finished tree afterwards. + expect(install).toContain('.orca-remote/relay-0.1.0+testhash') + const promote = commands.findLast((c) => c.includes('mkdir "$cache"')) ?? '' + expect(promote).toContain(': > "$cache/.deps-complete"') + }) + + it('does not publish a tree the probe could not load', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_MISS, [ + '', // npm install + '', // chmod prebuilds + 'MISSING\n', + '', // cat probe stderr + '', // rm probe stderr + '', // npm rebuild + '', // chmod prebuilds after rebuild + 'MISSING\n', + '', // cat stderr after rebuild + '', // rm stderr after rebuild + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((c) => c.includes('mkdir "$cache"'))).toBe(false) + }) + + it('installs per-directory when the host cannot answer the cache probe at all', async () => { + const conn = makeMockConnection(sftpCapture) + const prefix = makeStagedFirstInstallExecPrefix() + prefix[prefix.length - 1] = { reject: 'mkdir: Read-only file system' } + feed([ + ...prefix, + '', // npm install still runs + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + '', // publication is attempted and answers nothing + ...LAUNCH_TAIL + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((c) => c.includes('npm install'))).toBe(true) + }) + + it('falls back to its own install when a linked entry does not load on this host', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds + 'MISSING\n', // the shared tree does not load here + '', // cat probe stderr + '', // rm probe stderr + '', // npm install, privately, after the prefix detaches the symlink + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + '', // promotion attempt (the entry already exists, so it is declined) + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((c) => c.includes('npm install')) ?? '' + // Why this prefix is the whole point: npm follows the symlink, and every other relay on the + // host is running out of the tree on the other side of it. + expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') + const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnings.some((m) => m.includes('[ssh-relay][NATIVE-CACHE-UNUSABLE]'))).toBe(true) + }) + + it('detaches rather than resetting through the link when repairing an installed relay', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + bothMissing, // health probe before the repair lock + bothMissing, // re-probe under the lock + '', // install-owner marker + '', // npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const commands = execCommands() + // A repair never consults the shared entry: its reset would rewrite a tree it does not own. + expect(commands.some((c) => c.includes('.orca-remote/native/'))).toBe(false) + const install = commands.find((c) => c.includes('npm install')) ?? '' + expect(install).toContain('if [ -L node_modules ]; then rm -f node_modules; fi;') + expect(install).toContain("rm -rf 'node_modules/node-pty'") + }) + + it('pins its own key so a GC pass cannot collect the entry this connection depends on', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + ...LAUNCH_TAIL + ]) + ) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + await vi.waitFor(() => expect(vi.mocked(gcOldRelayVersions)).toHaveBeenCalled()) + + const options = vi.mocked(gcOldRelayVersions).mock.calls.at(-1)?.[4] + expect(options?.nativeDepsCacheKeys).toEqual([ + expect.stringMatching(/^linux-x64-[0-9a-f]{16}$/) + ]) + }) +}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache-gc.ts b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts new file mode 100644 index 00000000000..e2349ad1bbf --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-gc.ts @@ -0,0 +1,243 @@ +/** + * Garbage collection for the shared native-deps cache. + * + * This is the part that can hurt: a cache entry is the only copy of node-pty for every relay + * directory that links to it, so a wrong deletion takes native modules away from a running relay. + * The discipline is `remote-install-gc.ts`': **an unanswered probe blocks deletion.** A listing + * that does not end in its own OK token, a symlink whose target will not read, a reference whose + * shape this client does not recognise — each aborts the entire pass rather than narrowing it. + * Loss of contact is never evidence that a tree is unreferenced + * (`docs/reference/ssh-execution-boundary.md`). + * + * Deletion is then the same three-step move `remote-install-gc.ts` uses for version dirs: rename + * to a tombstone, re-read the references under the rename, and only then remove. A deploy that + * linked the entry between the first listing and the rename shows up in the recheck, and its tree + * is moved back. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + isRelayNativeDepsCacheEntryName, + relayNativeDepsCacheBaseDir, + relayNativeDepsCacheNodeModulesPath, + supportsRelayNativeDepsCache, + RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX +} from './ssh-relay-native-deps-cache' +import { + listRelayNativeDepsCacheEntriesCommand, + listRelayNativeDepsCacheReferencesCommand, + MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES, + RELAY_NATIVE_CACHE_LIST_OK, + RELAY_NATIVE_CACHE_REFS_OK +} from './ssh-relay-native-deps-cache-commands' +import { moveRemoteTreeCommand, removeRemoteTreeCommand } from './ssh-remote-commands' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +type ReferenceScan = + | { readable: true; referencedKeys: Set } + /** Anything this client could not fully account for. No entry may be deleted on it. */ + | { readable: false } + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string +): Promise { + return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' }) +} + +/** + * Remove complete cache entries that nothing links to. + * + * `pinnedKeys` is the connection's own key. The referencing symlink is written before the entry + * becomes listable, so a live entry is already protected by the reference scan; the pin is there + * so a deploy that fell back to a per-directory install cannot have its key deleted underneath a + * retry either. + */ +export async function gcRelayNativeDepsCache( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + options?: { pinnedKeys?: readonly string[] } +): Promise { + if (!supportsRelayNativeDepsCache(host)) { + return + } + const base = relayNativeDepsCacheBaseDir(host, remoteHome) + let entries: string[] + try { + entries = parseCacheEntryListing( + await execHostCommand(conn, host, listRelayNativeDepsCacheEntriesCommand(host, remoteHome)) + ) + } catch { + return + } + if (entries.length === 0) { + return + } + const scan = await scanCacheReferences(conn, host, remoteHome) + if (!scan.readable) { + return + } + const pinned = new Set(options?.pinnedKeys ?? []) + const candidates = entries.filter((key) => !scan.referencedKeys.has(key) && !pinned.has(key)) + const removed: string[] = [] + for (const key of candidates) { + if (await removeUnreferencedCacheEntry(conn, host, remoteHome, base, key)) { + removed.push(key) + } + } + if (removed.length > 0) { + console.log( + `[relay] native-deps cache GC: removed ${removed.length} entry(ies): ${removed.join(', ')}` + ) + } +} + +async function removeUnreferencedCacheEntry( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string, + base: string, + key: string +): Promise { + const entryDir = joinRemotePath(host, base, key) + const tombstone = joinRemotePath( + host, + base, + `${RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX}${key}.${process.pid}.${Date.now()}` + ) + try { + const moved = await execHostCommand( + conn, + host, + moveRemoteTreeCommand(host, entryDir, tombstone) + ) + if (moved.trim() !== 'MOVED') { + return false + } + } catch { + return false + } + // Why recheck under the rename: a deploy that read `.deps-complete` before it moved can still + // be creating its symlink. Its reference now names a path that no longer exists, so restoring + // the tree is the only outcome that leaves that relay with working native deps. + let recheck: ReferenceScan + try { + recheck = await scanCacheReferences(conn, host, remoteHome) + } catch { + recheck = { readable: false } + } + if (!recheck.readable || recheck.referencedKeys.has(key)) { + await execHostCommand(conn, host, moveRemoteTreeCommand(host, tombstone, entryDir)).catch( + () => {} + ) + return false + } + try { + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + return true + } catch { + // The sweep in the entry listing drains a tombstone this pass could not remove. + return false + } +} + +async function scanCacheReferences( + conn: SshConnection, + host: RemoteHostPlatform, + remoteHome: string +): Promise { + let output: string + try { + output = await execHostCommand( + conn, + host, + listRelayNativeDepsCacheReferencesCommand(host, remoteHome) + ) + } catch { + return { readable: false } + } + const lines = output.split(/\r?\n/).map((line) => line.trim()) + if (!lines.includes(RELAY_NATIVE_CACHE_REFS_OK)) { + return { readable: false } + } + const referencedKeys = new Set() + const base = relayNativeDepsCacheBaseDir(host, remoteHome) + for (const line of lines) { + if (!line.startsWith('REF ')) { + continue + } + const attribution = attributeReference(line.slice('REF '.length), base, host, remoteHome) + if (attribution.kind === 'unattributable') { + return { readable: false } + } + if (attribution.kind === 'entry') { + referencedKeys.add(attribution.key) + } + } + return { readable: true, referencedKeys } +} + +type ReferenceAttribution = + | { kind: 'entry'; key: string } + /** A link that points somewhere else entirely; it holds no cache entry alive. */ + | { kind: 'outside' } + | { kind: 'unattributable' } + +/** + * Which cache entry a symlink target names. + * + * A relative target is `unattributable` on purpose. Every link Orca writes is absolute, so a + * relative one is a tree with a history this pass cannot reconstruct, and guessing which entry it + * resolves to is exactly the inference that deletes a live relay's modules. + */ +function attributeReference( + target: string, + base: string, + host: RemoteHostPlatform, + remoteHome: string +): ReferenceAttribution { + if (!target.startsWith('/') || target.includes('/../') || target.endsWith('/..')) { + return { kind: 'unattributable' } + } + if (!target.startsWith(`${base}/`)) { + return { kind: 'outside' } + } + const rest = target.slice(base.length + 1).split('/') + if ( + rest.length !== 2 || + rest[1] !== 'node_modules' || + !isRelayNativeDepsCacheEntryName(rest[0]) + ) { + return { kind: 'unattributable' } + } + // Why rebuild the path rather than trust the split: the target must be exactly what this client + // writes for that key, not merely something that parses into two plausible segments. + return target === relayNativeDepsCacheNodeModulesPath(host, remoteHome, rest[0]) + ? { kind: 'entry', key: rest[0] } + : { kind: 'unattributable' } +} + +function parseCacheEntryListing(output: string): string[] { + const lines = output.split(/\r?\n/).map((line) => line.trim()) + if (!lines.includes(RELAY_NATIVE_CACHE_LIST_OK)) { + return [] + } + const entries: string[] = [] + for (const line of lines) { + if (!line.startsWith('ENTRY ')) { + continue + } + const name = line.slice('ENTRY '.length) + // Why re-validate a name the host produced: it is about to be interpolated into `mv` and + // `rm -rf`. Only names this client could itself have minted are eligible. + if ( + isRelayNativeDepsCacheEntryName(name) && + entries.length < MAX_RELAY_NATIVE_CACHE_LISTING_ENTRIES + ) { + entries.push(name) + } + } + return entries +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-install.ts b/src/main/ssh/ssh-relay-native-deps-cache-install.ts new file mode 100644 index 00000000000..adc1f01d54f --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-install.ts @@ -0,0 +1,209 @@ +/** + * The deploy-side half of the shared native-deps cache: resolve this build's key, try to link an + * existing entry, and publish a probe-verified tree afterwards. + * + * Both entry points answer with a value, never an exception. The cache is an optimization on a + * path that must still connect a host with a read-only home, no `ln`, or an SSH server that drops + * the channel — every one of those is a plain per-directory install, which is what the relay did + * before this existed. + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { SshConnection } from './ssh-connection' +import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' +import { execCommand } from './ssh-relay-deploy-helpers' +import { NATIVE_DEPS_COMMAND_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { + computeRelayNativeDepsCacheKey, + supportsRelayNativeDepsCache, + RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN, + type RelayNativeDepsCachePatchSource +} from './ssh-relay-native-deps-cache' +import { + ensureRelayNativeDepsCacheCommand, + promoteRelayNativeDepsCacheCommand, + RELAY_NATIVE_CACHE_LINKED, + RELAY_NATIVE_CACHE_PROMOTED, + RELAY_NATIVE_CACHE_SEEDED, + type RelayNativeDepsCachePaths +} from './ssh-relay-native-deps-cache-commands' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +/** + * `linked` — the relay directory now points at a complete shared entry and needs no install. + * `private` — it owns (or is about to own) its own tree, which promotion may later publish. + */ +export type RelayNativeDepsCacheAttachment = { + mode: 'linked' | 'private' + key: string +} + +export type RelayNativeDepsCacheContext = { + hostPlatform: RemoteHostPlatform + remoteHome: string + relayDir: string + platform: string + localRelayDir: string + deps: Readonly> + signal?: AbortSignal +} + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + // Why the native-deps budget and not the default 30s: a seeding copy moves a whole + // node_modules on the host's own disk, which is fast but not instant on a cold cache. + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) +} + +/** + * Every shipped artifact that patches the installed native tree, read for hashing. + * + * Reading is best-effort by design: a patch this client cannot read must not silently drop out of + * the key, so an unreadable one disables the cache rather than producing a key that claims the + * patch was applied. + */ +export function readRelayNativeDepsPatchSources( + localRelayDir: string +): RelayNativeDepsCachePatchSource[] | null { + const sources: RelayNativeDepsCachePatchSource[] = [] + for (const artifact of RELAY_ARTIFACTS) { + if (!RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(artifact.filename)) { + continue + } + const path = join(localRelayDir, artifact.filename) + try { + if (!existsSync(path)) { + continue + } + sources.push({ filename: artifact.filename, contents: readFileSync(path, 'utf-8') }) + } catch { + return null + } + } + return sources +} + +/** This build's cache key, or null when it cannot be computed and the cache must stay off. */ +export function resolveRelayNativeDepsCacheKey(context: { + platform: string + localRelayDir: string + deps: Readonly> +}): string | null { + const patchSources = readRelayNativeDepsPatchSources(context.localRelayDir) + if (!patchSources) { + return null + } + try { + return computeRelayNativeDepsCacheKey({ + platform: context.platform, + deps: context.deps, + patchSources + }) + } catch (err) { + console.warn( + `[ssh-relay] Native-deps cache key unavailable for ${context.platform}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return null + } +} + +/** + * Link a complete entry, or leave the relay directory to install privately. + * + * Returns null when the cache is off for this host, which keeps the caller on the exact command + * sequence it ran before the cache existed. + */ +export async function attachRelayNativeDepsCache( + conn: SshConnection, + context: RelayNativeDepsCacheContext +): Promise { + if (!supportsRelayNativeDepsCache(context.hostPlatform)) { + return null + } + const key = resolveRelayNativeDepsCacheKey(context) + if (!key) { + return null + } + const paths = cachePathsFor(context, key) + try { + const output = await execHostCommand( + conn, + context.hostPlatform, + ensureRelayNativeDepsCacheCommand(paths, context.deps), + context.signal + ) + if (output.includes(RELAY_NATIVE_CACHE_LINKED)) { + console.log(`[ssh-relay] Native deps linked from shared cache entry ${key}`) + return { mode: 'linked', key } + } + if (output.includes(RELAY_NATIVE_CACHE_SEEDED)) { + console.log(`[ssh-relay] Seeded native deps for ${key} from an existing install on this host`) + } + return { mode: 'private', key } + } catch (err) { + context.signal?.throwIfAborted() + // Why still 'private' and not null: the relay directory owns nothing yet either way, and the + // install that follows is identical. Promotion afterwards is separately best-effort. + console.warn( + `[ssh-relay] Native-deps cache probe for ${key} failed; installing per-directory: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return { mode: 'private', key } + } +} + +/** + * Publish a private tree the probe just loaded, and link the relay directory to it. + * + * Called only after `probeInstalledNativeDeps` reported both addons loadable on this host, so an + * entry is never published on the strength of a successful `npm install` alone. + */ +export async function promoteRelayNativeDepsCache( + conn: SshConnection, + context: RelayNativeDepsCacheContext, + key: string +): Promise { + try { + const output = await execHostCommand( + conn, + context.hostPlatform, + promoteRelayNativeDepsCacheCommand(cachePathsFor(context, key)), + context.signal + ) + console.log( + output.includes(RELAY_NATIVE_CACHE_PROMOTED) + ? `[ssh-relay] Published native deps as shared cache entry ${key}` + : `[ssh-relay] Native deps stay per-directory; shared cache entry ${key} was not published` + ) + } catch (err) { + context.signal?.throwIfAborted() + console.warn( + `[ssh-relay] Could not publish native-deps cache entry ${key}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + } +} + +function cachePathsFor( + context: RelayNativeDepsCacheContext, + key: string +): RelayNativeDepsCachePaths { + return { + host: context.hostPlatform, + remoteHome: context.remoteHome, + relayDir: context.relayDir, + key + } +} diff --git a/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts new file mode 100644 index 00000000000..530eed7238e --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache-shell.test.ts @@ -0,0 +1,243 @@ +// The cache's safety argument is made of `sh`, not TypeScript: `mkdir` elects the publisher, +// `.deps-complete` gates linking, and a failed publish must put the tree back. Asserting on the +// command strings cannot show any of that, so these run the real scripts against a real tree. + +import { execFileSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, + existsSync, + lstatSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + computeRelayNativeDepsCacheKey, + relayNativeDepsCacheEntryDir, + relayNativeDepsCacheNodeModulesPath +} from './ssh-relay-native-deps-cache' +import { + ensureRelayNativeDepsCacheCommand, + listRelayNativeDepsCacheEntriesCommand, + listRelayNativeDepsCacheReferencesCommand, + promoteRelayNativeDepsCacheCommand, + RELAY_NATIVE_CACHE_LINKED, + RELAY_NATIVE_CACHE_LIST_OK, + RELAY_NATIVE_CACHE_MISS, + RELAY_NATIVE_CACHE_NOT_PROMOTED, + RELAY_NATIVE_CACHE_PROMOTED, + RELAY_NATIVE_CACHE_REFS_OK, + RELAY_NATIVE_CACHE_SEEDED +} from './ssh-relay-native-deps-cache-commands' + +const HOST = getRemoteHostPlatform('linux-x64') +const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const +const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) + +// Debian and Ubuntu point /bin/sh at dash, which is stricter than the bash-in-sh-mode that macOS +// ships; run against both when both exist so a bashism cannot pass here and fail on a host. +const SHELLS = ['/bin/sh', '/bin/dash'].filter((shell) => existsSync(shell)) + +describe.runIf(process.platform !== 'win32').each(SHELLS)( + 'relay native-deps cache shell scripts (%s)', + (shell) => { + let home: string + + const relayDir = (version: string): string => join(home, '.orca-remote', `relay-${version}`) + + function sh(command: string): string { + return execFileSync(shell, ['-c', command], { encoding: 'utf-8' }) + } + + /** A relay directory holding its own installed tree, exactly as `npm install` leaves it. */ + function makePrivateInstall(version: string, deps: Record = DEPS): string { + const dir = relayDir(version) + mkdirSync(join(dir, 'node_modules', 'node-pty', 'build'), { recursive: true }) + mkdirSync(join(dir, 'node_modules', '@parcel', 'watcher'), { recursive: true }) + writeFileSync(join(dir, 'node_modules', 'node-pty', 'build', 'pty.node'), 'binary') + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: deps })) + return dir + } + + function ensure(version: string): string { + return sh( + ensureRelayNativeDepsCacheCommand( + { host: HOST, remoteHome: home, relayDir: relayDir(version), key: KEY }, + DEPS + ) + ).trim() + } + + function promote(version: string): string { + return sh( + promoteRelayNativeDepsCacheCommand({ + host: HOST, + remoteHome: home, + relayDir: relayDir(version), + key: KEY + }) + ).trim() + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-relay-cache-')) + mkdirSync(join(home, '.orca-remote'), { recursive: true }) + }) + + afterEach(() => { + rmSync(home, { recursive: true, force: true }) + }) + + it('publishes a probe-verified tree and links the relay directory to it', () => { + makePrivateInstall('0.1.0+aaa') + + expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) + + const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) + expect(readlinkSync(join(relayDir('0.1.0+aaa'), 'node_modules'))).toBe(target) + expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + expect( + existsSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) + ).toBe(true) + }) + + it('links a second bundle to the published tree with no install of its own', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + + // A different bundle: fresh directory, no node_modules, nothing installed. + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_LINKED) + + const linked = join(relayDir('0.1.0+bbb'), 'node_modules') + expect(readlinkSync(linked)).toBe(relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)) + expect(statSync(join(linked, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + }) + + it('will not link an entry whose completion sentinel is absent', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + rmSync(join(relayNativeDepsCacheEntryDir(HOST, home, KEY), '.deps-complete')) + + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) + }) + + it('seeds from a sibling install rather than recompiling once more', () => { + makePrivateInstall('0.1.0+aaa') + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_SEEDED) + + const seeded = join(relayDir('0.1.0+bbb'), 'node_modules') + expect(lstatSync(seeded).isSymbolicLink()).toBe(false) + expect(statSync(join(seeded, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + // The source is untouched, so a relay running out of it is unaffected. + expect(existsSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'))).toBe(true) + }) + + it('refuses to seed from a sibling pinned to different versions', () => { + makePrivateInstall('0.1.0+aaa', { 'node-pty': '1.0.0', '@parcel/watcher': '2.5.6' }) + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + expect(existsSync(join(relayDir('0.1.0+bbb'), 'node_modules'))).toBe(false) + }) + + it('refuses to seed from a sibling that had node-pty skipped', () => { + makePrivateInstall('0.1.0+aaa') + rmSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty'), { recursive: true }) + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + }) + + it('leaves a directory that installed for itself alone', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + const own = makePrivateInstall('0.1.0+bbb') + + expect(ensure('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_MISS) + expect(lstatSync(join(own, 'node_modules')).isSymbolicLink()).toBe(false) + }) + + it('elects exactly one publisher and leaves the loser its own tree', () => { + makePrivateInstall('0.1.0+aaa') + makePrivateInstall('0.1.0+bbb') + + expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_PROMOTED) + expect(promote('0.1.0+bbb')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) + + // The loser must not have handed its tree to an entry it lost the race for. + const loser = join(relayDir('0.1.0+bbb'), 'node_modules') + expect(lstatSync(loser).isSymbolicLink()).toBe(false) + expect(statSync(join(loser, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + }) + + it('never republishes through a symlink it already holds', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + + expect(promote('0.1.0+aaa')).toBe(RELAY_NATIVE_CACHE_NOT_PROMOTED) + expect(statSync(join(relayDir('0.1.0+aaa'), 'node_modules', 'node-pty')).isDirectory()).toBe( + true + ) + }) + + it('survives removing a linked relay directory without touching the shared tree', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + ensure('0.1.0+bbb') + + // Exactly what version GC does to an idle directory. + sh(`rm -rf ${JSON.stringify(relayDir('0.1.0+bbb'))}`) + + const target = relayNativeDepsCacheNodeModulesPath(HOST, home, KEY) + expect(statSync(join(target, 'node-pty', 'build', 'pty.node')).isFile()).toBe(true) + }) + + it('reports the published entry and every symlink that references it', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + + const entries = sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim().split('\n') + expect(entries).toEqual([`ENTRY ${KEY}`, RELAY_NATIVE_CACHE_LIST_OK]) + + const refs = sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim().split('\n') + expect(refs).toEqual([ + `REF ${relayNativeDepsCacheNodeModulesPath(HOST, home, KEY)}`, + RELAY_NATIVE_CACHE_REFS_OK + ]) + }) + + it('reports a symlink no Orca version wrote, so GC can refuse the pass', () => { + makePrivateInstall('0.1.0+aaa') + promote('0.1.0+aaa') + mkdirSync(relayDir('0.1.0+bbb'), { recursive: true }) + symlinkSync('../relay-0.1.0+aaa/node_modules', join(relayDir('0.1.0+bbb'), 'node_modules')) + + const refs = sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim().split('\n') + expect(refs).toContain('REF ../relay-0.1.0+aaa/node_modules') + expect(refs.at(-1)).toBe(RELAY_NATIVE_CACHE_REFS_OK) + }) + + it('answers cleanly on a host that has never installed anything', () => { + expect(sh(listRelayNativeDepsCacheEntriesCommand(HOST, home)).trim()).toBe( + RELAY_NATIVE_CACHE_LIST_OK + ) + expect(sh(listRelayNativeDepsCacheReferencesCommand(HOST, home)).trim()).toBe( + RELAY_NATIVE_CACHE_REFS_OK + ) + }) + } +) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.test.ts b/src/main/ssh/ssh-relay-native-deps-cache.test.ts new file mode 100644 index 00000000000..bfe0c481983 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache.test.ts @@ -0,0 +1,277 @@ +// The cache is shared across every relay directory on a host, so these cover the two things that +// make sharing safe: the key changes when the tree's inputs change, and GC refuses to delete on +// anything short of a complete, attributable reference listing. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn() +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + computeRelayNativeDepsCacheKey, + isRelayNativeDepsCacheEntryName, + relayNativeDepsCacheEntryDir, + relayNativeDepsCacheNodeModulesPath, + supportsRelayNativeDepsCache +} from './ssh-relay-native-deps-cache' +import { + ensureRelayNativeDepsCacheCommand, + promoteRelayNativeDepsCacheCommand, + RELAY_NATIVE_CACHE_LIST_OK, + RELAY_NATIVE_CACHE_REFS_ERR, + RELAY_NATIVE_CACHE_REFS_OK +} from './ssh-relay-native-deps-cache-commands' +import { gcRelayNativeDepsCache } from './ssh-relay-native-deps-cache-gc' + +const POSIX = getRemoteHostPlatform('linux-x64') +const WINDOWS = getRemoteHostPlatform('win32-x64') +const HOME = '/home/u' +const DEPS = { 'node-pty': '1.1.0', '@parcel/watcher': '2.5.6' } as const +const KEY = computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS }) +const RELAY_DIR = `${HOME}/.orca-remote/relay-0.1.0+aaa` + +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) + +function refsOk(...targets: string[]): string { + return [...targets.map((t) => `REF ${t}`), RELAY_NATIVE_CACHE_REFS_OK].join('\n') +} + +function listing(...keys: string[]): string { + return [...keys.map((k) => `ENTRY ${k}`), RELAY_NATIVE_CACHE_LIST_OK].join('\n') +} + +describe('computeRelayNativeDepsCacheKey', () => { + it('keys on the dependency set, not on the relay bundle', () => { + expect(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps: DEPS })).toBe(KEY) + expect( + computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: { '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' } + }) + ).toBe(KEY) + }) + + it('mints a new entry when a dependency version moves', () => { + expect( + computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: { ...DEPS, 'node-pty': '1.2.0' } + }) + ).not.toBe(KEY) + }) + + it('mints a new entry when a patch applied to the tree changes', () => { + const withPatch = computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: DEPS, + patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'a' }] + }) + const withChangedPatch = computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps: DEPS, + patchSources: [{ filename: 'node-pty-1.1.0-patch.cjs', contents: 'b' }] + }) + expect(withPatch).not.toBe(KEY) + expect(withChangedPatch).not.toBe(withPatch) + }) + + it('separates platforms so one host never links another architecture', () => { + expect(computeRelayNativeDepsCacheKey({ platform: 'linux-arm64', deps: DEPS })).not.toBe(KEY) + expect(KEY.startsWith('linux-x64-')).toBe(true) + }) + + it('refuses a platform it cannot recognise rather than building a path from it', () => { + expect(() => computeRelayNativeDepsCacheKey({ platform: '../../etc', deps: DEPS })).toThrow( + /Unsafe relay native-deps cache key/ + ) + expect(isRelayNativeDepsCacheEntryName('../../etc')).toBe(false) + expect(isRelayNativeDepsCacheEntryName(KEY)).toBe(true) + }) + + it('leaves Windows on the per-directory install', () => { + expect(supportsRelayNativeDepsCache(POSIX)).toBe(true) + expect(supportsRelayNativeDepsCache(WINDOWS)).toBe(false) + }) +}) + +describe('ensureRelayNativeDepsCacheCommand', () => { + const command = ensureRelayNativeDepsCacheCommand( + { host: POSIX, remoteHome: HOME, relayDir: RELAY_DIR, key: KEY }, + DEPS + ) + + it('links only an entry that carries the completion sentinel', () => { + expect(command).toContain(`[ -f "$cache/.deps-complete" ]`) + expect(command).toContain('ln -s "$target" "$nm"') + expect(command).toContain(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY)) + }) + + it('never overwrites a directory the relay installed for itself', () => { + // The link is only created on a path that does not exist; a real node_modules reads as a miss. + expect(command).toContain('if [ ! -e "$nm" ] && ln -s "$target" "$nm"') + }) + + it('seeds only from a sibling whose manifest pins the same versions', () => { + for (const [name, version] of Object.entries(DEPS)) { + expect(command).toContain(`grep -F -q '"${name}":"${version}"' "$pj"`) + } + expect(command).toContain('[ -d "$cand/node-pty" ] || continue') + }) +}) + +describe('promoteRelayNativeDepsCacheCommand', () => { + const command = promoteRelayNativeDepsCacheCommand({ + host: POSIX, + remoteHome: HOME, + relayDir: RELAY_DIR, + key: KEY + }) + + it('elects one publisher with mkdir rather than a lock', () => { + expect(command).toContain('mkdir "$cache" 2>/dev/null') + }) + + it('writes the completion sentinel after the symlink exists', () => { + expect(command.indexOf('ln -s "$target" "$nm"')).toBeLessThan( + command.indexOf(': > "$cache/.deps-complete"') + ) + }) + + it('never deletes the entry unless the tree made it back to the relay directory', () => { + expect(command).toContain('if mv "$target" "$nm" 2>/dev/null; then rm -rf "$cache"') + }) + + it('refuses to publish a directory that is already a shared symlink', () => { + expect(command).toContain('if [ -L "$nm" ]; then') + }) +}) + +describe('gcRelayNativeDepsCache', () => { + beforeEach(() => { + mockExec.mockReset().mockResolvedValue('') + }) + + it('removes an entry nothing links to', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + const last = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(last).toContain('rm -rf') + expect(last).toContain('.gc-tombstone.') + }) + + it('keeps an entry a live relay depends on', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY))) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + }) + + it('keeps every entry when the reference listing never answers', async () => { + mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce('REF /somewhere\n') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('keeps every entry when the reference scan reports an unreadable link', async () => { + mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce(RELAY_NATIVE_CACHE_REFS_ERR) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('keeps every entry when a reference has a shape this client never writes', async () => { + // A relative target cannot be attributed to an entry without guessing what it resolves to. + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk('../native/x/node_modules')) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('ignores a link that points outside the cache entirely', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk('/opt/shared/node_modules')) + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce(refsOk('/opt/shared/node_modules')) + .mockResolvedValueOnce('') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(true) + }) + + it('restores the tree when a deploy links the entry after the tombstone rename', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce(refsOk(relayNativeDepsCacheNodeModulesPath(POSIX, HOME, KEY))) + .mockResolvedValueOnce('MOVED') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + const last = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(last).toContain('mv ') + expect(last).toContain(relayNativeDepsCacheEntryDir(POSIX, HOME, KEY)) + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + }) + + it('restores the tree when the recheck itself cannot answer', async () => { + mockExec + .mockResolvedValueOnce(listing(KEY)) + .mockResolvedValueOnce(refsOk()) + .mockResolvedValueOnce('MOVED') + .mockRejectedValueOnce(new Error('channel closed')) + .mockResolvedValueOnce('MOVED') + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec.mock.calls.some(([, c]) => c.startsWith('rm -rf'))).toBe(false) + }) + + it('never removes a pinned key', async () => { + mockExec.mockResolvedValueOnce(listing(KEY)).mockResolvedValueOnce(refsOk()) + + await gcRelayNativeDepsCache(conn, POSIX, HOME, { pinnedKeys: [KEY] }) + + expect(mockExec).toHaveBeenCalledTimes(2) + }) + + it('drops a listed name it could not have minted rather than interpolating it', async () => { + mockExec + .mockResolvedValueOnce(`ENTRY ../../.ssh\n${RELAY_NATIVE_CACHE_LIST_OK}`) + .mockResolvedValueOnce(refsOk()) + + await gcRelayNativeDepsCache(conn, POSIX, HOME) + + expect(mockExec).toHaveBeenCalledTimes(1) + }) + + it('does nothing on a host that never creates entries', async () => { + await gcRelayNativeDepsCache(conn, WINDOWS, 'C:\\Users\\u') + + expect(mockExec).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-native-deps-cache.ts b/src/main/ssh/ssh-relay-native-deps-cache.ts new file mode 100644 index 00000000000..8400a467a91 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-cache.ts @@ -0,0 +1,151 @@ +/** + * Where the relay's compiled native dependencies live, and what their identity is keyed on. + * + * A relay install directory is keyed on the JS bundle hash, which moves on every commit to + * `src/relay/` or the `src/shared/` it pulls in. `node_modules` used to live inside it, so a + * dependency set that is a pinned constant (`RELAY_NATIVE_DEPS`) was reinstalled — and on Linux, + * where node-pty ships no prebuild, recompiled from source — on every new bundle (#18009). The + * directory key was coupled to the wrong quantity. + * + * The tree now lives at `~/.orca-remote/native/-/node_modules` and each + * relay directory holds a symlink to it. Three rules make one tree safe to share: + * + * 1. **A published entry is immutable.** `.deps-complete` is written last, only after a probe on + * this host loaded both addons. Nothing installs, rebuilds or resets into a published entry: a + * repair detaches the symlink and installs privately, so a host with a broken toolchain can + * never `rm -rf node_modules/node-pty` out from under a live relay that shares the tree. + * 2. **Publication elects one winner with `mkdir`.** The entry either does not exist (this deploy + * builds it privately and promotes it) or is already complete (this deploy links it). There is + * no window in which two deploys write one tree, so no client-side lock is needed. + * 3. **Every failure degrades to today's per-directory install.** A host that cannot symlink, + * cannot create the directory, or answers nothing still deploys, one bundle at a time. + * + * Windows is deliberately excluded. node-pty's npm tarball ships win32 prebuilts, so there is no + * compile to avoid there, and `node-pty-1.1.0-console-list-agent-patch.cjs` mutates the installed + * tree in place — which rule 1 forbids for a shared one. + * + * Linux's `node-pty-1.1.0-master-cloexec-patch.cjs` also mutates in place, but it stays inside rule + * 1: the deploy path runs it before promotion, and returns early on a linked entry, so it only ever + * touches a private tree. Its bytes are in the key, so a patched build never links a pre-patch + * entry -- and a tree whose patch was refused or rolled back is not promoted at all, because under + * that same key it would publish the leak to every later host on the machine. + */ +import { createHash } from 'node:crypto' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { RELAY_BUILD_PLATFORMS } from '../../shared/relay-artifacts' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +/** Sibling of `relay-` and `orcad-`; owned by neither model's version GC. */ +export const RELAY_NATIVE_DEPS_CACHE_DIR_NAME = 'native' + +/** Written last. Its presence is the only thing that makes an entry linkable. */ +export const RELAY_NATIVE_DEPS_CACHE_COMPLETE_NAME = '.deps-complete' + +/** Hidden so the entry listing skips it, and swept by age so a crashed pass drains. */ +export const RELAY_NATIVE_DEPS_CACHE_TOMBSTONE_PREFIX = '.gc-tombstone.' + +/** + * Bump when the remote install starts mutating the installed tree in a way the hashed inputs + * below cannot see — a new `npm rebuild` flag, a new post-install step, a patch applied by + * something other than a shipped `node-pty-*` artifact. A published entry is never repaired in + * place; only a new key retires it. + */ +export const RELAY_NATIVE_DEPS_CACHE_EPOCH = 1 + +/** + * Shipped relay artifacts that patch the installed native tree. Their bytes go into the key, so + * changing a patch mints a new entry instead of leaving hosts on a tree built from the old one. + */ +export const RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN = /^node-pty-.*\.(cjs|js|patch)$/ + +const CACHE_KEY_HASH_LENGTH = 16 + +const CACHE_ENTRY_NAME_REGEX = new RegExp( + `^(${RELAY_BUILD_PLATFORMS.join('|')})-[0-9a-f]{${CACHE_KEY_HASH_LENGTH}}$` +) + +export type RelayNativeDepsCachePatchSource = { + filename: string + contents: string +} + +/** + * `-` over the dependency set, the epoch, and every patch the + * remote install applies. Platform and arch stay in the name rather than the hash so an operator + * reading `~/.orca-remote/native/` can tell what an entry is for. + */ +export function computeRelayNativeDepsCacheKey(input: { + platform: string + deps: Readonly> + patchSources?: readonly RelayNativeDepsCachePatchSource[] +}): string { + const hash = createHash('sha256') + hash.update(`epoch ${RELAY_NATIVE_DEPS_CACHE_EPOCH}\n`) + for (const [name, version] of Object.entries(input.deps).sort(([a], [b]) => (a < b ? -1 : 1))) { + hash.update(`dep ${name} ${version}\n`) + } + const patches = [...(input.patchSources ?? [])].sort((a, b) => (a.filename < b.filename ? -1 : 1)) + for (const patch of patches) { + hash.update( + `patch ${patch.filename} ${createHash('sha256').update(patch.contents).digest('hex')}\n` + ) + } + const key = `${input.platform}-${hash.digest('hex').slice(0, CACHE_KEY_HASH_LENGTH)}` + if (!isRelayNativeDepsCacheEntryName(key)) { + // Why: the key reaches the host inside `mv` and `rm -rf`; an unrecognized platform must + // disable the cache rather than arrive as a path fragment nobody validated. + throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) + } + return key +} + +/** + * Whether a name the host listed is one this client may move or delete. Every GC candidate goes + * through here before it reaches a shell. + */ +export function isRelayNativeDepsCacheEntryName(name: string): boolean { + return CACHE_ENTRY_NAME_REGEX.test(name) +} + +/** `~/.orca-remote` — the parent both relay dirs and the cache sit under. */ +export function remoteInstallRootDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) +} + +/** `~/.orca-remote/native` */ +export function relayNativeDepsCacheBaseDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath( + host, + remoteInstallRootDir(host, remoteHome), + RELAY_NATIVE_DEPS_CACHE_DIR_NAME + ) +} + +/** `~/.orca-remote/native/` */ +export function relayNativeDepsCacheEntryDir( + host: RemoteHostPlatform, + remoteHome: string, + key: string +): string { + if (!isRelayNativeDepsCacheEntryName(key)) { + throw new Error(`Unsafe relay native-deps cache key: ${JSON.stringify(key)}`) + } + return joinRemotePath(host, relayNativeDepsCacheBaseDir(host, remoteHome), key) +} + +/** `~/.orca-remote/native//node_modules` — the symlink target, and the reference identity. */ +export function relayNativeDepsCacheNodeModulesPath( + host: RemoteHostPlatform, + remoteHome: string, + key: string +): string { + return joinRemotePath(host, relayNativeDepsCacheEntryDir(host, remoteHome, key), 'node_modules') +} + +/** + * Windows hosts install node-pty from an npm prebuilt and then patch the tree in place, so they + * keep the per-directory install. Nothing else about their deploy changes. + */ +export function supportsRelayNativeDepsCache(host: RemoteHostPlatform): boolean { + return !isWindowsRemoteHost(host) +} diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts index e8fc2274583..5cd20a03438 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts @@ -15,6 +15,9 @@ export type SftpWriteCapture = { type SftpCallback = (err: Error | null, resolved?: string) => void const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) +// Stdout of the relay-side pty-master cloexec patch; kept as a literal so the fixture states the +// wire token it is standing in for rather than importing the module under test. +const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' export function makeMockConnection(capture: SftpWriteCapture): SshConnection { // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. @@ -54,6 +57,11 @@ export function makeMockConnection(capture: SftpWriteCapture): SshConnection { export type ExecResponse = string | { reject: string } +// The answer a genuinely broken pair produces: a marker line naming both deps. A bare `MISSING` +// names none, so it is unverifiable and must never stand in for this. +export const BOTH_NATIVE_DEPS_MISSING_PROBE = + 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' + const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' export function makeStagedFirstInstallExecPrefix(): ExecResponse[] { @@ -64,19 +72,20 @@ export function makeStagedFirstInstallExecPrefix(): ExecResponse[] { `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0`, '', // chmod staged node '', // final install namespace marker - `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` + `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED`, + // Shared native-deps cache probe; an empty answer is a miss, so the per-directory install runs. + '' ] } // Repair reconnect (isRelayAlreadyInstalled → true) where BOTH native deps are broken and the host // cannot compile node-pty, so the caller's resets must survive into the node-pty-less reinstall. export function makeRepairToolchainSkipExecResponses(): ExecResponse[] { - const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' return [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - bothMissing, // health probe before lock - bothMissing, // re-probe under the repair lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe before lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock '', // SFTP-namespace install-owner marker (repair) { reject: 'gyp ERR! stack Error: not found: make' }, 'PKG apk', // toolchain probe: no HAVE lines @@ -139,7 +148,7 @@ export function makeExecResponses(opts: { '', // rm -rf node-pty + reinstall without it // node-pty is always reported missing here; the probe never resolves OK, so cat + rm both run. opts.nodePtySkipWatcher === 'missing' - ? 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING\n' + ? `${BOTH_NATIVE_DEPS_MISSING_PROBE}\n` : 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', '', // cat probe stderr '', // rm -f probe stderr @@ -168,8 +177,10 @@ export function makeExecResponses(opts: { ] // Cleanup execs only run when the probe resolved (not when it rejected). const probeResolved = typeof probeSlot === 'string' + let loadable = false if (probeResolved) { const probeOk = probeSlot.includes('ORCA-NPTY-PROBE-OK') + loadable = probeOk if (!probeOk) { slots.push('') // cat stderr (graceful failure path captures detail) } @@ -179,12 +190,20 @@ export function makeExecResponses(opts: { slots.push('') // chmod prebuilds after rebuild const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' slots.push(repairProbe) - if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { + loadable = repairProbe.includes('ORCA-NPTY-PROBE-OK') + if (!loadable) { slots.push('') // cat stderr after unsuccessful rebuild } slots.push('') // rm -f stderr after rebuild probe } } + // Publication is gated on the probe: only a tree this host actually loaded is shared. + if (loadable) { + // The cloexec patch runs first, and publication is gated on its status, so `patched` is what + // makes the promote exec below reachable at all. + slots.push(`${NODE_PTY_CLOEXEC_STATUS_PREFIX}patched\n`) + slots.push('') // promote the private tree into the shared native-deps cache + } slots.push('', 'DEAD', '', 'READY') // clean stage root, launch, credential, readiness return slots } diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 5297da7c85a..01625816170 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -83,6 +83,7 @@ import { import { acquireInstallLock } from './ssh-relay-install-lock' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { + BOTH_NATIVE_DEPS_MISSING_PROBE, decodePowerShellCommand, makeExecResponses, makeMockConnection, @@ -629,6 +630,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + 'ORCA-NPTY-CLOEXEC:patched\n', // pty-master cloexec patch on the loadable node-pty 'DEAD', '', // publish the per-launch credential 'READY' @@ -677,8 +679,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // health probe: require() fails - 'MISSING', // re-probe after lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // health probe: require() names both deps + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe after lock '', // SFTP-namespace install-owner marker (repair) { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) 'DEAD', @@ -701,8 +703,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(execCommand) .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce('MISSING') - .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) + .mockResolvedValueOnce(BOTH_NATIVE_DEPS_MISSING_PROBE) .mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair) .mockRejectedValueOnce( Object.assign(new Error('npm termination was not confirmed'), { @@ -843,7 +845,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', + BOTH_NATIVE_DEPS_MISSING_PROBE, 'DEAD', '', // remote credential generation without a namespace marker 'READY' diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts index c67270db4c3..3939c954f65 100644 --- a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -75,13 +75,19 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' import { parseUnameToRelayPlatform } from './relay-protocol' +import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { + BOTH_NATIVE_DEPS_MISSING_PROBE, + decodePowerShellCommand, makeMockConnection, type ExecResponse, type SftpWriteCapture } from './ssh-relay-native-deps-install-fixture' +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" @@ -156,18 +162,73 @@ describe('native-deps repair probe verdicts', () => { expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) }) - it('still resets and repairs when the probe answers without the OK marker', async () => { + it('leaves node_modules intact when the probe answers without naming a dep', async () => { + // The bare `MISSING` a `|| echo MISSING` subshell emits when node never reached the script + // (bad NODE_OPTIONS, OOM kill, exit 127). The shell answered; the answer is not about the deps. const conn = makeMockConnection(sftpCapture) feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // answered, no marker line: both deps are genuinely broken - 'MISSING', // re-probe under the repair lock + 'MISSING', // answered, no marker line: nothing here names a dep + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + const outcome = await deployAndLaunchRelay(conn).then( + (result) => result, + (err: Error) => err + ) + + const commands = execCommands() + expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( + false + ) + expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) + expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) + expect(commands.some((command) => command.includes('npm install'))).toBe(false) + // One probe only: an unverifiable answer must not fall through to the locked re-probe. + expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(outcome, 'an unparseable answer must not abort the connection').not.toBeInstanceOf(Error) + expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( + true + ) + }) + + it('carries the probe stderr into the unparseable-answer warning', async () => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockImplementationOnce((_conn, _command, options) => { + options?.onStderr?.('node: --inspect-brk is not allowed in NODE_OPTIONS') + return Promise.resolve('MISSING') + }) + feed(['', 'DEAD', '', 'READY']) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + // Why: `2>/dev/null` used to drop the one line that says which host config broke node. + expect( + warnings().find((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE')) + ).toContain('not allowed in NODE_OPTIONS') + }) + + it('still resets both deps when the probe names both', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + BOTH_NATIVE_DEPS_MISSING_PROBE, // answered: both deps are genuinely broken + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the repair lock '', // SFTP-namespace install-owner marker (repair) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' @@ -181,6 +242,63 @@ describe('native-deps repair probe verdicts', () => { expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) }) + it('leaves a Windows relay intact when its probe answers without naming a dep', async () => { + // The PowerShell branch has the same hole: `try { & node -e ... } catch { 'MISSING' }` prints + // nothing when node exits non-zero without reaching the script. + vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64') + vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', + 'C:\\Users\\u', + '', // health probe: PowerShell swallowed the native failure, so nothing names a dep + '', // no persisted active pipe marker + 'WAITING', // initial pipe probe + '', // publish the per-launch credential + '', // WMI relay launch + 'READY', // readiness poll + '' // persist active pipe marker + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const scripts = execCommands().map((command) => decodePowerShellCommand(command) ?? command) + expect(scripts.some((script) => script.includes('node_modules/node-pty'))).toBe(false) + expect(scripts.some((script) => script.includes('node_modules/@parcel/watcher'))).toBe(false) + expect(scripts.some((script) => script.includes('npm install'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(warnings().some((message) => message.includes('NATIVE-DEPS-PROBE-UNPARSEABLE'))).toBe( + true + ) + }) + + it('resets only the dep the probe names', async () => { + const conn = makeMockConnection(sftpCapture) + const watcherMissing = 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING' + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + watcherMissing, + watcherMissing, // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(WATCHER_RESET) + expect(install).not.toContain(NODE_PTY_RESET) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + it('skips repair entirely when the probe answers OK', async () => { const conn = makeMockConnection(sftpCapture) feed([ @@ -211,6 +329,7 @@ describe('native-deps repair probe verdicts', () => { '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' diff --git a/src/main/ssh/ssh-relay-node-pty-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-repair.test.ts new file mode 100644 index 00000000000..7c1b1800259 --- /dev/null +++ b/src/main/ssh/ssh-relay-node-pty-repair.test.ts @@ -0,0 +1,204 @@ +// Why: the once-only ledger is the whole safety story here — an unbounded rebuild loop against a +// remote is worse than the bug it chases, so every gate that stops one gets a test. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + forgetRelayNodePtyRepairs, + recoverRelayNodePtyForSpawn, + relayNodePtyRepairAttempts +} from './ssh-relay-node-pty-repair' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' + +const HOST: TerminalUnavailableCause['host'] = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' +} + +function cause(overrides: Partial = {}): TerminalUnavailableCause { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', + repairable: true, + host: HOST, + ...overrides + } +} + +describe('recoverRelayNodePtyForSpawn', () => { + const TARGET = 'host-a' + let warnSpy: ReturnType + + beforeEach(() => { + forgetRelayNodePtyRepairs(TARGET) + forgetRelayNodePtyRepairs('host-b') + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + forgetRelayNodePtyRepairs(TARGET) + forgetRelayNodePtyRepairs('host-b') + }) + + function harness(overrides: { hasLivePtys?: boolean; reconnect?: () => Promise } = {}) { + const reconnect = vi.fn(overrides.reconnect ?? (async () => {})) + const repaired = { name: 'post-repair-provider' } + const resolveProvider = vi.fn(() => repaired) + return { + reconnect, + resolveProvider, + repaired, + run: (c: TerminalUnavailableCause | null) => + recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: c, + hasLivePtys: () => overrides.hasLivePtys === true, + reconnect, + resolveProvider + }) + } + } + + it('repairs a repairable cause with exactly one reconnect and hands back the new provider', async () => { + const h = harness() + + const result = await h.run(cause()) + + expect(result.outcome).toBe('repaired') + expect(result.provider).toBe(h.repaired) + expect(h.reconnect).toHaveBeenCalledTimes(1) + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual(['abi_mismatch']) + }) + + it('does not repair a second time for the same cause on the same host', async () => { + const first = harness() + await first.run(cause()) + const second = harness() + + const result = await second.run(cause()) + + expect(result.outcome).toBe('already-attempted') + expect(result.provider).toBeNull() + expect(second.reconnect).not.toHaveBeenCalled() + }) + + it('spends the attempt even when the repair reconnect fails, so it cannot loop', async () => { + const failing = harness({ + reconnect: async () => { + throw new Error('relay repair lock is wedged') + } + }) + + const first = await failing.run(cause()) + expect(first.outcome).toBe('reconnect-failed') + expect(first.provider).toBeNull() + + const second = harness() + const retry = await second.run(cause()) + + expect(retry.outcome).toBe('already-attempted') + expect(second.reconnect).not.toHaveBeenCalled() + }) + + it('keeps the ledger per host, so a second host still gets its one attempt', async () => { + const h = harness() + await h.run(cause()) + const other = vi.fn(async () => {}) + + const result = await recoverRelayNodePtyForSpawn({ + targetId: 'host-b', + cause: cause(), + hasLivePtys: () => false, + reconnect: other, + resolveProvider: () => ({}) + }) + + expect(result.outcome).toBe('repaired') + expect(other).toHaveBeenCalledTimes(1) + }) + + it('keeps the ledger per reason, so a different proved fault still gets its one attempt', async () => { + const h = harness() + await h.run(cause()) + const second = harness() + + const result = await second.run(cause({ reason: 'arch_mismatch' })) + + expect(result.outcome).toBe('repaired') + expect([...relayNodePtyRepairAttempts(TARGET)].sort()).toEqual([ + 'abi_mismatch', + 'arch_mismatch' + ]) + }) + + it('never repairs an unverifiable cause, whatever the peer claims about repairability', async () => { + const h = harness() + + // Why repairable:true here: #14830 is exactly a peer flag believed over the status. + const result = await h.run(cause({ status: 'unverifiable', repairable: true })) + + expect(result.outcome).toBe('not-repairable') + expect(h.reconnect).not.toHaveBeenCalled() + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) + }) + + it('never repairs a toolchain_missing cause — the rebuild needs the missing compiler', async () => { + const h = harness() + + const result = await h.run(cause({ reason: 'toolchain_missing', repairable: false })) + + expect(result.outcome).toBe('not-repairable') + expect(h.reconnect).not.toHaveBeenCalled() + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) + }) + + it('never repairs when the relay published no cause at all', async () => { + const h = harness() + + const result = await h.run(null) + + expect(result.outcome).toBe('not-repairable') + expect(h.reconnect).not.toHaveBeenCalled() + }) + + it('does not rebuild under live PTYs, and does not spend the attempt doing so', async () => { + const live = harness({ hasLivePtys: true }) + + const blocked = await live.run(cause()) + expect(blocked.outcome).toBe('ptys-live') + expect(live.reconnect).not.toHaveBeenCalled() + expect([...relayNodePtyRepairAttempts(TARGET)]).toEqual([]) + + const idle = harness() + expect((await idle.run(cause())).outcome).toBe('repaired') + }) + + it('withholds the retry when the reconnect produced no provider', async () => { + const reconnect = vi.fn(async () => {}) + + const result = await recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: cause(), + hasLivePtys: () => false, + reconnect, + resolveProvider: () => null + }) + + expect(result.outcome).toBe('no-provider') + expect(result.provider).toBeNull() + expect(reconnect).toHaveBeenCalledTimes(1) + }) + + it('gives a host a fresh attempt only after an explicit disconnect', async () => { + await harness().run(cause()) + forgetRelayNodePtyRepairs(TARGET) + const afterDisconnect = harness() + + expect((await afterDisconnect.run(cause())).outcome).toBe('repaired') + }) +}) diff --git a/src/main/ssh/ssh-relay-node-pty-repair.ts b/src/main/ssh/ssh-relay-node-pty-repair.ts new file mode 100644 index 00000000000..149b6c486cb --- /dev/null +++ b/src/main/ssh/ssh-relay-node-pty-repair.ts @@ -0,0 +1,115 @@ +/** + * Turning a spawn-time "remote terminals are unavailable" into an actual repair. + * + * The fault is proved on the relay at spawn time; the only thing that can fix it — + * `repairInstalledNativeDeps` in ssh-relay-deploy.ts — runs on the client during deploy, under + * `tryAcquireRelayRepairLock`. So the recovery here is deliberately indirect: it does not touch + * the remote `node_modules` itself, it drives one relay reconnect and lets the locked deploy path + * do the rebuild. All `node_modules` mutation stays behind that lock. + * + * The invariant that matters more than the recovery: **at most one repair per host per reason.** + * A rebuild loop against a remote is worse than the bug it is chasing, so the attempt is recorded + * before the reconnect starts, not after it succeeds. A repair that ran and failed is still an + * attempt, and this host will render the relay's message from then on. + */ +import { + mayRepairFromCause, + type TerminalUnavailableCause +} from '../../shared/terminal-unavailable-cause' + +/** targetId -> the cause reasons already spent on this host, for the life of the session. */ +const attemptedRepairsByTarget = new Map>() + +export type RelayNodePtyRepairOutcome = + /** The cause is not proved-and-rebuildable; render the relay's message. */ + | 'not-repairable' + /** This host already spent its one attempt on this reason. */ + | 'already-attempted' + /** The relay is still serving PTYs, so a rebuild under it is not accounted for. */ + | 'ptys-live' + /** No reconnect was possible, or it failed; the attempt is still spent. */ + | 'reconnect-failed' + /** Reconnected, but no provider came back to retry on. */ + | 'no-provider' + | 'repaired' + +/** Forget a host's spent attempts. Disconnect is user action, so it may earn a fresh one. */ +export function forgetRelayNodePtyRepairs(targetId: string): void { + attemptedRepairsByTarget.delete(targetId) +} + +/** Test/diagnostic view of the ledger. */ +export function relayNodePtyRepairAttempts(targetId: string): ReadonlySet { + return attemptedRepairsByTarget.get(targetId) ?? new Set() +} + +/** False when this host has already spent its attempt on this reason. Marks on success. */ +function claimRepairAttempt(targetId: string, reason: string): boolean { + const spent = attemptedRepairsByTarget.get(targetId) + if (spent) { + if (spent.has(reason)) { + return false + } + spent.add(reason) + return true + } + attemptedRepairsByTarget.set(targetId, new Set([reason])) + return true +} + +export type RelayNodePtyRepairRequest = { + targetId: string + /** Already parsed and schema-validated; null when the relay published no cause. */ + cause: TerminalUnavailableCause | null + /** Whether this client still holds live PTYs on the relay about to be rebuilt. */ + hasLivePtys: () => boolean + /** One relay reconnect, which runs the locked `repairInstalledNativeDeps`. */ + reconnect: () => Promise + /** The provider registered after the reconnect — never the one that failed. */ + resolveProvider: () => TProvider | null +} + +/** + * Drive one repair for a failed spawn, returning the provider to retry on. + * + * Gates on `mayRepairFromCause`, not on the peer's `repairable` flag: an `unverifiable` cause + * proves nothing and must never trigger a rebuild (#14830, docs/reference/ssh-execution-boundary.md). + */ +export async function recoverRelayNodePtyForSpawn( + request: RelayNodePtyRepairRequest +): Promise<{ outcome: RelayNodePtyRepairOutcome; provider: TProvider | null }> { + const { targetId, cause } = request + if (!mayRepairFromCause(cause) || !cause) { + return { outcome: 'not-repairable', provider: null } + } + // Why check before claiming: a live PTY means the rebuild's blast radius is unaccounted for, and + // that is a reason to wait, not a spent attempt. Costs nothing remote, so it cannot loop. + if (request.hasLivePtys()) { + console.warn( + `[ssh-relay-repair] Not rebuilding node-pty on ${targetId} for ${cause.reason}: the relay is still serving PTYs` + ) + return { outcome: 'ptys-live', provider: null } + } + if (!claimRepairAttempt(targetId, cause.reason)) { + console.warn( + `[ssh-relay-repair] node-pty repair for ${cause.reason} on ${targetId} already ran; not retrying` + ) + return { outcome: 'already-attempted', provider: null } + } + + console.warn( + `[ssh-relay-repair] Reconnecting ${targetId} once to rebuild node-pty (${cause.reason}): ${cause.detail}` + ) + try { + await request.reconnect() + } catch (error) { + console.warn( + `[ssh-relay-repair] Repair reconnect for ${targetId} failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + return { outcome: 'reconnect-failed', provider: null } + } + const provider = request.resolveProvider() + return provider ? { outcome: 'repaired', provider } : { outcome: 'no-provider', provider: null } +} diff --git a/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts new file mode 100644 index 00000000000..8981b6319a8 --- /dev/null +++ b/src/main/ssh/ssh-relay-node-pty-spawn-repair.test.ts @@ -0,0 +1,292 @@ +// The other half of the #17830 recovery: proof that the reconnect a spawn-time cause triggers is +// the SAME locked deploy repair, not a second rebuild path. `tryAcquireRelayRepairLock` is the only +// thing standing between two clients and a concurrent `node_modules` rewrite, so a lock this path +// cannot take must degrade to the relay's message rather than proceed. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: () => false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' +import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' + +const TARGET = 'repair-host' + +const ABI_MISMATCH: TerminalUnavailableCause = { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for NODE_MODULE_VERSION 108, this Node accepts 115', + repairable: true, + host: { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' + } +} + +// The relay dir is complete but node-pty will not load, which is exactly what the spawn-time cause +// describes. @parcel/watcher is healthy, so only node-pty is reset and rebuilt. +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' +const NODE_PTY_BROKEN = 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING' + +function repairSucceedsResponses(): ExecResponse[] { + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + NODE_PTY_BROKEN, // health probe before the lock + NODE_PTY_BROKEN, // re-probe under the repair lock + '', // SFTP-namespace install-owner marker + '', // reset node-pty + npm install + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', // node-pty loads again + '', // rm -f probe stderr + NPTY_CLOEXEC_PATCHED, + 'DEAD', + '', // publish the per-launch credential + 'READY' + ] +} + +function lockUnavailableResponses(): ExecResponse[] { + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + NODE_PTY_BROKEN, // health probe before the lock + 'DEAD', + '', // publish the per-launch credential + 'READY' + ] +} + +describe('spawn-time node-pty repair through the locked deploy path', () => { + let warnSpy: ReturnType + const sftpCapture: SftpWriteCapture = { + paths: [], + contents: {}, + execCallCountAtWrite: {} + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') + forgetRelayNodePtyRepairs(TARGET) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + forgetRelayNodePtyRepairs(TARGET) + }) + + function feed(responses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of responses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function recover(conn: ReturnType, deploys: { count: number }) { + return recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: ABI_MISMATCH, + hasLivePtys: () => false, + reconnect: async () => { + deploys.count += 1 + await deployAndLaunchRelay(conn) + }, + resolveProvider: () => ({ generation: deploys.count }) + }) + } + + function execCalls(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => String(command)) + } + + it('rebuilds node-pty under the repair lock and returns the post-reconnect provider', async () => { + const conn = makeMockConnection(sftpCapture) + feed(repairSucceedsResponses()) + const deploys = { count: 0 } + + const result = await recover(conn, deploys) + + expect(result.outcome).toBe('repaired') + expect(result.provider).toEqual({ generation: 1 }) + expect(deploys.count).toBe(1) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + const install = execCalls().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain('npm install') + // The reset is what makes an ABI-mismatched binding recompile instead of being reported up to date. + expect(install).toContain("rm -rf 'node_modules/node-pty'") + }) + + it('does not repair or reconnect a second time for the same cause on the same host', async () => { + const conn = makeMockConnection(sftpCapture) + feed(repairSucceedsResponses()) + const deploys = { count: 0 } + await recover(conn, deploys) + vi.mocked(execCommand).mockReset().mockResolvedValue('') + + const second = await recover(conn, deploys) + + expect(second.outcome).toBe('already-attempted') + expect(second.provider).toBeNull() + expect(deploys.count).toBe(1) + expect(execCalls()).toEqual([]) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + }) + + it.each(['busy', 'error'] as const)( + 'leaves the host untouched and degrades to the relay message when the repair lock is %s', + async (lockResult) => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) + feed(lockUnavailableResponses()) + const deploys = { count: 0 } + + const result = await recover(conn, deploys) + + // The reconnect happened; the rebuild did not, so the retried spawn hits the same relay + // rejection and the user reads today's message. Nothing wrote to node_modules unlocked. + expect(deploys.count).toBe(1) + expect(execCalls().some((command) => command.includes('npm install'))).toBe(false) + expect(execCalls().some((command) => command.includes('node_modules/node-pty'))).toBe(false) + expect(result.outcome).toBe('repaired') + const warnings = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnings.some((line) => line.includes(`repair lock is ${lockResult}`))).toBe(true) + } + ) + + it('never reaches the deploy path for an unverifiable cause', async () => { + const conn = makeMockConnection(sftpCapture) + const deploys = { count: 0 } + + const result = await recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: { ...ABI_MISMATCH, status: 'unverifiable' }, + hasLivePtys: () => false, + reconnect: async () => { + deploys.count += 1 + await deployAndLaunchRelay(conn) + }, + resolveProvider: () => ({ generation: deploys.count }) + }) + + expect(result.outcome).toBe('not-repairable') + expect(deploys.count).toBe(0) + expect(vi.mocked(tryAcquireRelayRepairLock)).not.toHaveBeenCalled() + expect(execCalls()).toEqual([]) + }) + + it('never reaches the deploy path for a toolchain_missing cause', async () => { + const conn = makeMockConnection(sftpCapture) + const deploys = { count: 0 } + + const result = await recoverRelayNodePtyForSpawn({ + targetId: TARGET, + cause: { ...ABI_MISMATCH, reason: 'toolchain_missing', repairable: false }, + hasLivePtys: () => false, + reconnect: async () => { + deploys.count += 1 + await deployAndLaunchRelay(conn) + }, + resolveProvider: () => ({ generation: deploys.count }) + }) + + expect(result.outcome).toBe('not-repairable') + expect(deploys.count).toBe(0) + expect(execCalls()).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts index 9d08ac7da0f..b63f93a6c2b 100644 --- a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts +++ b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import { isProvenProcessExit } from '../../shared/terminal-exit-cause' const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -186,14 +187,18 @@ describe('SshRelaySession abandoned remote PTYs', () => { expect(clearProviderPtyState).not.toHaveBeenCalledWith(APP_PTY_ID) }) - it('retires the lease without a kill when the relay proves the PTY is gone', async () => { - // pty.attach verifies process liveness before answering not-found, so this is the one branch - // with positive proof of death — and a dead process needs no shutdown request. + it('stops claiming the id without asserting an exit when the relay answers not-found', async () => { + // pty.attach answers not-found both when it verified the pid is dead AND when its session map + // simply has no such id — which is every id after a relay restart, since the relay renumbers + // from pty-1. The client cannot tell those apart, so this branch may release the id but must + // not certify a death: the exit it publishes carries the unverified-loss sentinel, never a + // status the renderer would read as a real exit. const { deps, shutdown } = await establishWithFailingReattach( new Error('PTY "pty-live" not found') ) expect(shutdown).not.toHaveBeenCalled() + // 'expired' records that reattach gave up on the id, not that the shell died; ssh:terminateSessions still reaches it. expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( 'target-1', 'pty-live', @@ -204,6 +209,15 @@ describe('SshRelaySession abandoned remote PTYs', () => { id: APP_PTY_ID, code: -1 }) + const exitCall = vi + .mocked(deps.mockWindow.webContents.send) + .mock.calls.find(([channel]) => channel === 'pty:exit') + if (!exitCall) { + throw new Error('expected a pty:exit publication') + } + // The ratchet that makes the above safe: swapping -1 for any provable status would turn an + // unreachable relay into a death certificate, closing tabs and dropping leaf↔PTY bindings. + expect(isProvenProcessExit((exitCall[1] as { code: number }).code)).toBe(false) }) it('keeps a recovered session attached when reattach succeeds after an earlier drop', async () => { diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts new file mode 100644 index 00000000000..66af01fa43c --- /dev/null +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { + makeExecResponses, + makeStagedFirstInstallExecPrefix, + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' +import { RELAY_NATIVE_CACHE_LINKED } from './ssh-relay-native-deps-cache-commands' +import { RELAY_ARTIFACTS } from '../../shared/relay-artifacts' +import { + computeRelayNativeDepsCacheKey, + RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN +} from './ssh-relay-native-deps-cache' + +const PATCH_ASSET = 'node-pty-1.1.0-master-cloexec-patch.cjs' + +/** + * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and every + * later child of the relay inherits a live pty master (#17915). The compile that closes it sits on + * the connect path, so what these specs pin is the blast radius, not the patch itself. + */ +describe('relay pty-master close-on-exec patch on the install path', () => { + const sftpCapture: SftpWriteCapture = { + paths: [], + contents: {}, + execCallCountAtWrite: {} + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + sftpCapture.paths.length = 0 + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function firstInstall(cacheAnswer: string, tail: ExecResponse[]): ExecResponse[] { + const prefix = makeStagedFirstInstallExecPrefix() + // The prefix's last slot is the shared native-deps cache probe. + prefix[prefix.length - 1] = cacheAnswer + return [...prefix, ...tail] + } + + function patchCommands(): string[] { + return vi + .mocked(execCommand) + .mock.calls.map(([, command]) => command) + .filter((command) => command.includes(PATCH_ASSET)) + } + + /** Whether this deploy elected itself publisher of the shared entry. */ + function promoted(): boolean { + return vi + .mocked(execCommand) + .mock.calls.some(([, command]) => command.includes('mkdir "$cache"')) + } + + /** + * A cache-miss first install whose patch reports `status`. The promote slot is fed either way, + * so a run that wrongly promotes reads a valid response rather than falling off the end -- the + * assertion has to be the absence of the command itself, not a downstream crash. + */ + function firstInstallReporting(status: string): ExecResponse[] { + return [ + ...makeStagedFirstInstallExecPrefix(), + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + `ORCA-NPTY-CLOEXEC:${status}\n`, + '', // promote into the shared native-deps cache, if this deploy still gets that far + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' + ] + } + + it('runs the patch on a Linux relay once node-pty is proven loadable', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toHaveLength(1) + expect(patchCommands()[0]).toContain("'/usr/bin/node'") + }) + + it('patches the private tree before it is published to the shared native-deps cache', async () => { + // Promotion moves `node_modules` into `~/.orca-remote/native/` and leaves a symlink + // behind, and a published entry is immutable by contract. Patching afterwards would rename, + // rebuild and roll back inside a tree every other relay on the host links -- and the + // `.deps-complete` written by promotion would have published an unpatched tree that every + // later host links and skips. The ordering is invisible in review, so pin it. + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + const patchAt = commands.findIndex((command) => command.includes(PATCH_ASSET)) + const promoteAt = commands.findIndex((command) => command.includes('mkdir "$cache"')) + expect(patchAt).toBeGreaterThan(-1) + expect(promoteAt).toBeGreaterThan(-1) + expect(patchAt).toBeLessThan(promoteAt) + }) + + it('does not publish a tree whose patch failed and rolled back', async () => { + // The script rolls `pty.cc` and `build/Release` back to the pre-patch, still-leaky build and + // reports `failed:` with exit 0, so nothing throws. Publishing that tree would be worse than + // the leak this PR closes: the key hashes the patch's bytes, so every later host on the + // machine links the entry, probes it loadable, and skips patching. Stay private instead. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('failed:npm rebuild node-pty failed: gyp ERR! not found: make')) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toHaveLength(1) + expect(promoted()).toBe(false) + expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( + '[ssh-relay][NPTY-CLOEXEC-UNSHARED]' + ) + } finally { + warn.mockRestore() + } + }) + + it('does not publish a tree the patch refused to touch', async () => { + // `skipped:` is not one verdict. Every form except `skipped:not-linux` means the patch was + // declined and the leaky build is still on disk, which is indistinguishable from `failed:` + // as far as what would get published. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('skipped:earlier-attempt-failed')) + + await deployAndLaunchRelay(conn) + + expect(promoted()).toBe(false) + // A refusal exits 0, so the warn is the only signal that this host stayed leaky. + expect(warn.mock.calls.map((args) => String(args[0] ?? '')).join('\n')).toContain( + '[ssh-relay][NPTY-CLOEXEC-UNFIXED]' + ) + } finally { + warn.mockRestore() + } + }) + + it('still publishes a tree that was patched but whose isolation check could not run', async () => { + // `patched-unverified` rebuilt from patched source; only the check that watches a later child + // could not observe the result. An unobservable check is not a failed patch, and refusing to + // publish here would disable the shared cache on every host without `lsof`. + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('patched-unverified')) + + await deployAndLaunchRelay(conn) + + expect(promoted()).toBe(true) + }) + + it('never patches through a symlink into an entry another relay already published', async () => { + // A linked entry was built under a key that hashes this patch's bytes, so it is already + // patched; re-running the patch would rebuild inside the shared tree. + const conn = makeMockConnection(sftpCapture) + feed( + firstInstall(RELAY_NATIVE_CACHE_LINKED, [ + '', // chmod prebuilds, through the symlink + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + ) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toEqual([]) + }) + + it('leaves an unloadable node-pty alone rather than rebuilding it blind', async () => { + // A relay that could not build node-pty has nothing to fall back to, and the existing + // reinstall path owns that repair. + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: 'ok', + probe: 'missing', + repairProbe: 'missing' + }) + ) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toEqual([]) + }) + + it('never adds a compile to a macOS relay, which does not leak the master', async () => { + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') + const conn = makeMockConnection(sftpCapture) + feed([ + ...makeStagedFirstInstallExecPrefix(), + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // promote into the shared native-deps cache + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toEqual([]) + }) + + it('connects anyway when the patch command fails outright', async () => { + const conn = makeMockConnection(sftpCapture) + const responses = makeExecResponses({ npmInstall: 'ok', probe: 'ok' }) + const patchSlot = responses.findIndex( + (response) => typeof response === 'string' && response.includes('ORCA-NPTY-CLOEXEC:') + ) + expect(patchSlot).toBeGreaterThan(-1) + responses[patchSlot] = { reject: 'no such file or directory' } + feed(responses) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + }) +}) + +describe('the shipped patch is part of the shared native-deps cache key', () => { + it('mints a new entry, so a pre-fix unpatched tree is never linked by a patched build', () => { + const artifact = RELAY_ARTIFACTS.find((entry) => entry.filename === PATCH_ASSET) + expect(artifact).toBeDefined() + // A windowsOnly artifact never reaches a Linux relay dir, so it would drop out of the key. + expect(artifact?.windowsOnly).toBeFalsy() + expect(RELAY_NATIVE_DEPS_PATCH_ARTIFACT_PATTERN.test(PATCH_ASSET)).toBe(true) + + const deps = { 'node-pty': '1.1.0' } + expect( + computeRelayNativeDepsCacheKey({ + platform: 'linux-x64', + deps, + patchSources: [{ filename: PATCH_ASSET, contents: 'patch bytes' }] + }) + ).not.toBe(computeRelayNativeDepsCacheKey({ platform: 'linux-x64', deps })) + }) +}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index bdc7c4370f2..68254fbb108 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -6,6 +6,9 @@ import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import { isRelayEndpointHeldError } from './ssh-relay-endpoint-incumbent' +import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-relay-node-pty-repair' +import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause' import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider } from '../providers/ssh-pty-provider' @@ -318,6 +321,8 @@ export class SshRelaySession { private _onReady: ((targetId: string) => void) | null = null private portScanner: PortScanner | null = null private currentConnection: SshConnection | null = null + // Why: a self-driven repair reconnect must not silently re-negotiate the target's grace window. + private lastGraceTimeSeconds: number | undefined = undefined private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null private aiVaultListMethodSupported: boolean | null = null @@ -516,6 +521,7 @@ export class SshRelaySession { this.aiVaultListMethodSupported = null this.aiVaultTitleMethodSupported = null this.currentConnection = conn + this.lastGraceTimeSeconds = graceTimeSeconds try { const { @@ -629,7 +635,13 @@ export class SshRelaySession { } // Why: terminal on first connect — a deployed binary against a still-running legacy daemon, or a // claim another connection holds. Notify the callback but still rethrow. - if (isRelayVersionMismatchError(err) || isSshOwnerAdmissionBlockedError(err)) { + // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the + // socket path, and backoff cannot make it hand it over. The user resolves it. + if ( + isRelayVersionMismatchError(err) || + isRelayEndpointHeldError(err) || + isSshOwnerAdmissionBlockedError(err) + ) { console.warn( `[ssh-relay-session] Terminal relay error on initial connect for ${this.targetId}: ${err.message}` ) @@ -656,6 +668,7 @@ export class SshRelaySession { this.aiVaultListMethodSupported = null this.aiVaultTitleMethodSupported = null this.currentConnection = conn + this.lastGraceTimeSeconds = graceTimeSeconds // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. this.stopPortScanning() @@ -783,7 +796,13 @@ export class SshRelaySession { } // Why terminal: neither a version mismatch nor a blocked owner claim is reconcilable by backoff // retry, so fire the typed callback and drop out of 'reconnecting'. - if (isRelayVersionMismatchError(err) || isSshOwnerAdmissionBlockedError(err)) { + // RelayEndpointHeldError is terminal for the same reason: a live incumbent owns the + // socket path, and backoff cannot make it hand it over. The user resolves it. + if ( + isRelayVersionMismatchError(err) || + isRelayEndpointHeldError(err) || + isSshOwnerAdmissionBlockedError(err) + ) { console.warn( `[ssh-relay-session] Terminal relay error for ${this.targetId}: ${err.message}` ) @@ -849,6 +868,9 @@ export class SshRelaySession { this.teardownProviders('shutdown') this.currentConnection = null this._state = 'disposed' + // Why here and not on reconnect: an explicit disconnect is user action, so the host earns a + // fresh node-pty repair attempt. A reconnect must not, or the repair becomes a loop. + forgetRelayNodePtyRepairs(this.targetId) const recoveryRemoval = forgetSshPtyConsumerRecovery( this.targetId, this.ptyConsumerClientInstanceId, @@ -982,6 +1004,44 @@ export class SshRelaySession { }) } + /** + * A spawn was refused because the relay cannot load node-pty. Reconnect once so the deploy + * path's `repairInstalledNativeDeps` rebuilds it under `tryAcquireRelayRepairLock`, then hand + * back the provider registered by that reconnect for a single retry. + * + * Nothing here mutates the remote directly — a lock-less rebuild could collide with a + * concurrent reconnect's repair, so the locked deploy path stays the only writer. If the lock + * is busy it launches degraded, the retry hits the same rejection, and the user sees the + * relay's message. The attempt is spent either way. + */ + private async recoverRemoteTerminalRuntime( + requestingProvider: SshPtyProvider, + cause: TerminalUnavailableCause + ): Promise { + const { provider } = await recoverRelayNodePtyForSpawn({ + targetId: this.targetId, + cause, + hasLivePtys: () => requestingProvider.hasLivePtys(), + reconnect: async () => { + const conn = this.currentConnection + if (!conn || this.isDisposed()) { + throw new Error('no_live_ssh_connection') + } + await this.reconnect(conn, this.lastGraceTimeSeconds) + }, + resolveProvider: () => { + if (this._state !== 'ready' || this.isDisposed()) { + return null + } + const current = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined + // Why identity-checked: a reconnect that fell back to the same provider would retry + // against the same unrepaired relay. + return current && current !== requestingProvider ? current : null + } + }) + return provider + } + // Why: shared by establish() and reconnect() so both use the exact same registration sequence. private async registerProviders( mux: SshChannelMultiplexer, @@ -1021,6 +1081,10 @@ export class SshRelaySession { this.remoteCliBridgeEnv ?? undefined, providerGeneration ) + // Why optional-call: session tests register partial provider stubs, same as the pause adapter below. + ptyProvider.setTerminalUnavailableRecovery?.((cause) => + this.recoverRemoteTerminalRuntime(ptyProvider, cause) + ) const consumerOwnerState = this.activePtyConsumerOwner() if (consumerOwnerState) { ptyProvider.setPtyDeliveryPauseAdapter?.(({ id, providerGeneration: generation, paused }) => { diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 3809af2c3d9..81142743cd1 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -85,12 +85,14 @@ import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { parseUnameToRelayPlatform } from './relay-protocol' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { abandonInstall, finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { BOTH_NATIVE_DEPS_MISSING_PROBE } from './ssh-relay-native-deps-install-fixture' import type { SshConnection } from './ssh-connection' import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' @@ -103,6 +105,9 @@ const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ +// Stdout of the relay-side pty-master cloexec patch, which runs on Linux hosts once a +// freshly installed node-pty loads (#17915). +const NPTY_CLOEXEC_PATCHED = 'ORCA-NPTY-CLOEXEC:patched\n' const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' const STAGE_RESERVED = `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0` const STAGE_PROMOTED = `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` @@ -154,8 +159,7 @@ function issuedMarkerNames(): string[] { } function decodeCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : command + return decodeRemotePowerShellScript(command) } function execCommands(): string[] { @@ -250,10 +254,13 @@ const POSIX_FIRST_INSTALL = [ '', // chmod staged node '', // final install namespace marker STAGE_PROMOTED, + '', // shared native-deps cache probe (miss) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + '', // promote into the shared native-deps cache '', // clean stage root 'DEAD', '', // publish the per-launch credential @@ -267,10 +274,13 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ STAGE_RESERVED, '', // chmod staged node STAGE_PROMOTED, + '', // shared native-deps cache probe (miss) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, + '', // promote into the shared native-deps cache '', // clean stage root 'DEAD', '', // publish the per-launch credential @@ -281,13 +291,14 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ const POSIX_REPAIR = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, - 'MISSING', // probe before the repair lock - 'MISSING', // re-probe under the lock + BOTH_NATIVE_DEPS_MISSING_PROBE, // probe before the repair lock: the marker names both deps + BOTH_NATIVE_DEPS_MISSING_PROBE, // re-probe under the lock '', // install-owner marker '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // publish the per-launch credential 'READY' @@ -692,12 +703,13 @@ describe('relay repair writes on a split SFTP namespace', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, - 'MISSING', - 'MISSING', + BOTH_NATIVE_DEPS_MISSING_PROBE, + BOTH_NATIVE_DEPS_MISSING_PROBE, '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // remote credential generation 'READY' @@ -716,13 +728,19 @@ describe('relay repair writes on a split SFTP namespace', () => { it('degrades to shell paths when marker creation fails outright', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + BOTH_NATIVE_DEPS_MISSING_PROBE, + BOTH_NATIVE_DEPS_MISSING_PROBE + ]) vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) feed([ '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + NPTY_CLOEXEC_PATCHED, 'DEAD', '', // remote credential generation 'READY' @@ -742,7 +760,12 @@ describe('relay repair writes on a split SFTP namespace', () => { it('keeps the repair lock when marker creation has unconfirmed termination', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + BOTH_NATIVE_DEPS_MISSING_PROBE, + BOTH_NATIVE_DEPS_MISSING_PROBE + ]) vi.mocked(execCommand).mockRejectedValueOnce( Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) ) diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts new file mode 100644 index 00000000000..874d9aae3fe --- /dev/null +++ b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const execCommand = vi.fn() +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: (...args: unknown[]) => execCommand(...args), + isUnconfirmedSshCommandTermination: (error: unknown) => + (error as { sshChannelCloseConfirmed?: boolean } | null)?.sshChannelCloseConfirmed === false +})) + +import { parseRelayEndpointIncumbentProbe } from './ssh-relay-endpoint-incumbent' +import { + classifySupersededRelay, + supersededRelayEndpointListCommand, + sweepSupersededRelayEndpoints +} from './ssh-relay-superseded-endpoints' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const HOME = '/home/u' +const SOCK_NAME = 'relay-deadbeef.sock' +const CURRENT_DIR = `${HOME}/.orca-remote/relay-0.1.0+bd3ec370d21d` +const OLD_SOCK = `${HOME}/.orca-remote/relay-0.1.0+7175e0a40ea7/${SOCK_NAME}` +const HOST = getRemoteHostPlatform('linux-x64') +const WINDOWS_HOST = getRemoteHostPlatform('win32-x64') +const CONN = {} as SshConnection + +const SWEEP = { + remoteHome: HOME, + currentRelayDir: CURRENT_DIR, + sockName: SOCK_NAME, + nodePath: '/usr/bin/node' +} + +function probe(lines: string[]): string { + return ['ORCA-INCUMBENT-BEGIN', ...lines, 'ORCA-INCUMBENT-END'].join('\n') +} + +function incumbent(lines: string[]): ReturnType { + return parseRelayEndpointIncumbentProbe(OLD_SOCK, probe(lines)) +} + +function issuedCommands(): string[] { + return execCommand.mock.calls.map((call) => String(call[1])) +} + +beforeEach(() => { + execCommand.mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +describe('supersededRelayEndpointListCommand', () => { + it('globs sibling version dirs for this target socket and skips the current one', () => { + const command = supersededRelayEndpointListCommand(SWEEP) + expect(command).toContain('"$base"/relay-*/"$sock_name"') + expect(command).toContain('[ "$dir" = "$current" ] && continue') + expect(command).toContain(SOCK_NAME) + expect(command).toContain(CURRENT_DIR) + }) +}) + +describe('classifySupersededRelay', () => { + it('retains a live relay that still owns PTYs', () => { + expect( + classifySupersededRelay( + incumbent([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=3669803 yes 13' + ]) + ) + ).toBe('retained-live-work') + }) + + it('nominates only a proven empty relay for reaping', () => { + expect( + classifySupersededRelay( + incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + ).toBe('reap-candidate') + }) + + it('removes only a socket proven to have no holder', () => { + expect( + classifySupersededRelay(incumbent(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) + ).toBe('stale-endpoint-removed') + }) + + it('does nothing at all for an unverifiable endpoint', () => { + expect( + classifySupersededRelay( + incumbent(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable']) + ) + ).toBe('unverifiable') + }) +}) + +describe('sweepSupersededRelayEndpoints', () => { + it('leaves an upgrade-orphaned relay that still owns terminals running, untouched', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + ) + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ sockPath: OLD_SOCK, outcome: 'retained-live-work' }) + expect(issuedCommands().some((command) => /\bkill\b/.test(command))).toBe(false) + expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) + }) + + it('reaps the empty husk an upgrade leaves behind, once the host confirms it is gone', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('GONE\n') + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('reaped') + expect(issuedCommands()[2]).toContain('kill -TERM "$pid"') + }) + + it('reports reap-unconfirmed rather than reaped when the pid is still there', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + ) + .mockResolvedValueOnce('LIVE\n') + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('reap-unconfirmed') + }) + + it('unlinks an orphaned socket only once nothing holds it, unpinning the dir for GC', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof'])) + .mockResolvedValueOnce('') + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('stale-endpoint-removed') + expect(issuedCommands()[2]).toBe(`rm -f '${OLD_SOCK}'`) + }) + + it('touches nothing on a host it cannot interrogate', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce(probe(['PRESENT=yes', 'LISTEN=unknown', 'HOLDERS_SOURCE=unavailable'])) + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) + expect(findings[0].outcome).toBe('unverifiable') + expect(issuedCommands()).toHaveLength(2) + }) + + it('is a no-op when the listing fails, and never guesses at what was there', async () => { + execCommand.mockRejectedValueOnce(new Error('exec failed')) + await expect(sweepSupersededRelayEndpoints(CONN, HOST, SWEEP)).resolves.toEqual([]) + }) + + it('does not run against Windows hosts, whose endpoints are named pipes', async () => { + await expect(sweepSupersededRelayEndpoints(CONN, WINDOWS_HOST, SWEEP)).resolves.toEqual([]) + expect(execCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.ts b/src/main/ssh/ssh-relay-superseded-endpoints.ts new file mode 100644 index 00000000000..1a9554f7b82 --- /dev/null +++ b/src/main/ssh/ssh-relay-superseded-endpoints.ts @@ -0,0 +1,178 @@ +/** + * Relays this target left behind at a *different* version directory. + * + * Every relay build installs to `~/.orca-remote/relay-/` and binds its socket + * inside it, so the socket path moves on every app update even though the filename component + * is stable. After an update the new client binds a path the previous relay's PTYs were never + * associated with, and the previous relay is never contacted again (#13614, #13852). Nothing + * signals it and nothing reclaims it: with `--grace-time 0` it keeps its shells and agents + * alive forever. + * + * This sweep makes that population *visible and deliberate* rather than silent. It does not + * make it recoverable — the daemon handshake compares the build's content hash exactly + * (`relay-handshake.ts`), so a new client cannot speak to an old daemon at all. See the report + * on this change for what a real cross-version handoff would require. + * + * The one thing it will terminate is a relay that provably holds nothing. Everything else is + * retained, including everything it merely failed to reach. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + describeRelayEndpointIncumbent, + isReapableRelayHusk, + probeRelayEndpointIncumbent, + type RelayEndpointIncumbent +} from './ssh-relay-endpoint-incumbent' +import { reapEmptyRelayHusk } from './ssh-relay-endpoint-takeover' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +/** + * `reaped` is the only outcome that claims a process ended, and it is only reachable from a + * post-signal `kill -0` that failed. A signal we sent but could not confirm is + * `reap-unconfirmed`, which is `unverifiable` — not `exited` by another name. + */ +export type SupersededRelayOutcome = + | 'reaped' + | 'reap-unconfirmed' + | 'retained-live-work' + | 'stale-endpoint-removed' + | 'unverifiable' + +export type SupersededRelayFinding = { + sockPath: string + outcome: SupersededRelayOutcome + incumbent: RelayEndpointIncumbent +} + +export type SupersededRelaySweepOptions = { + remoteHome: string + /** Absolute path of the version directory this client just launched into; never swept. */ + currentRelayDir: string + /** Stable per-target socket filename, from `relaySocketNameForInstanceId`. */ + sockName: string + nodePath: string + signal?: AbortSignal +} + +const MAX_SWEPT_ENDPOINTS = 32 + +export function supersededRelayEndpointListCommand(options: { + remoteHome: string + currentRelayDir: string + sockName: string +}): string { + return [ + `base=${shellEscape(`${options.remoteHome}/${RELAY_REMOTE_DIR}`)}`, + `sock_name=${shellEscape(options.sockName)}`, + `current=${shellEscape(options.currentRelayDir)}`, + 'for sock in "$base"/relay-*/"$sock_name"; do', + ' [ -S "$sock" ] || continue', + ' dir=${sock%/*}', + ' [ "$dir" = "$current" ] && continue', + ' printf \'%s\\n\' "$sock"', + 'done' + ].join('\n') +} + +/** Remove a socket inode proven to have no holder, so version-dir GC can reclaim the tree. */ +export function removeStaleRelayEndpointCommand(sockPath: string): string { + return `rm -f ${shellEscape(sockPath)}` +} + +export function classifySupersededRelay( + incumbent: RelayEndpointIncumbent +): Exclude | 'reap-candidate' { + if (incumbent.verdict === 'exited') { + return incumbent.socketPresent ? 'stale-endpoint-removed' : 'unverifiable' + } + if (incumbent.verdict !== 'live') { + return 'unverifiable' + } + return isReapableRelayHusk(incumbent) ? 'reap-candidate' : 'retained-live-work' +} + +export async function sweepSupersededRelayEndpoints( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + options: SupersededRelaySweepOptions +): Promise { + if (isWindowsRemoteHost(hostPlatform)) { + return [] + } + let listing: string + try { + listing = await execCommand(conn, supersededRelayEndpointListCommand(options), { + wrapCommand: true, + signal: options.signal + }) + } catch { + return [] + } + const sockPaths = listing + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('/')) + .slice(0, MAX_SWEPT_ENDPOINTS) + + const findings: SupersededRelayFinding[] = [] + for (const sockPath of sockPaths) { + options.signal?.throwIfAborted() + const incumbent = await probeRelayEndpointIncumbent( + conn, + hostPlatform, + options.nodePath, + sockPath, + { signal: options.signal } + ) + findings.push({ + sockPath, + outcome: await applySupersededRelayDecision(conn, incumbent, options), + incumbent + }) + } + logSupersededRelayFindings(findings) + return findings +} + +async function applySupersededRelayDecision( + conn: SshConnection, + incumbent: RelayEndpointIncumbent, + options: SupersededRelaySweepOptions +): Promise { + const decision = classifySupersededRelay(incumbent) + if (decision === 'stale-endpoint-removed') { + try { + await execCommand(conn, removeStaleRelayEndpointCommand(incumbent.sockPath), { + wrapCommand: true, + signal: options.signal + }) + return 'stale-endpoint-removed' + } catch { + return 'unverifiable' + } + } + if (decision !== 'reap-candidate') { + return decision + } + return reapEmptyRelayHusk(conn, incumbent, { signal: options.signal }) +} + +function logSupersededRelayFindings(findings: SupersededRelayFinding[]): void { + for (const finding of findings) { + const detail = describeRelayEndpointIncumbent(finding.incumbent) + if (finding.outcome === 'retained-live-work') { + console.warn( + `[ssh-relay] Superseded relay retained (holds live work; not signalled): ${detail}` + ) + continue + } + if (finding.outcome === 'unverifiable' || finding.outcome === 'reap-unconfirmed') { + console.warn(`[ssh-relay] Superseded relay ${finding.outcome}: ${detail}`) + continue + } + console.log(`[ssh-relay] Superseded relay ${finding.outcome}: ${detail}`) + } +} diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index b69715b23bf..363a87a645d 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { lockAgeSecondsCommand, tryCreateInstallLockCommand, @@ -63,8 +64,7 @@ const powerShell51Executable = : undefined function decodePowerShellCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' + return command.includes('-EncodedCommand ') ? decodeRemotePowerShellScript(command) : '' } function runShellCommand(command: string): Promise { diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts index cbc3b4faddc..8c94fd3c483 100644 --- a/src/main/ssh/ssh-remote-powershell.ts +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -1,9 +1,59 @@ +import { gunzipSync, gzipSync } from 'node:zlib' import { encodePowerShellCommand } from '../../shared/powershell-command-encoding' +import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args' export { quotePowerShellLiteral as powerShellLiteral, quotePowerShellNativeArgument as powerShellNativeArg } from '../../shared/powershell-native-argument' +// Why cmd.exe and not the 32767 CreateProcess cap: Windows OpenSSH runs every exec request +// through sshd's DefaultShell, cmd.exe on a stock install. Budget under cmd.exe's own ceiling +// to leave room for the `/c` wrapper sshd adds before cmd.exe counts the line. +const WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS = 8_000 + export function powerShellCommand(script: string): string { + const inline = encodedPowerShellCommand(script) + if (inline.length <= WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) { + return inline + } + // Why: these scripts are repetitive enough that gzip beats the UTF-16LE tax by + // ~4x, which is the difference between a line cmd.exe runs and one it refuses. + const compressed = encodedPowerShellCommand(selfExtractingPowerShellScript(script)) + if (compressed.length > WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS) { + throw new Error( + `Remote Windows command needs ${compressed.length} characters; Orca budgets ${WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS} for a line sshd hands to cmd.exe, which itself refuses more than ${CMD_EXE_COMMAND_LINE_MAX_CHARS}.` + ) + } + return compressed +} + +function encodedPowerShellCommand(script: string): string { return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` } + +/** Orca-prefixed names so the payload can never shadow the bootstrap's own state. */ +function selfExtractingPowerShellScript(script: string): string { + const payload = gzipSync(Buffer.from(script, 'utf-8'), { level: 9 }).toString('base64') + return [ + `$OrcaScriptBytes = [Convert]::FromBase64String('${payload}')`, + '$OrcaScriptMemory = New-Object System.IO.MemoryStream -ArgumentList (,$OrcaScriptBytes)', + '$OrcaScriptGzip = New-Object System.IO.Compression.GZipStream -ArgumentList $OrcaScriptMemory, ([System.IO.Compression.CompressionMode]::Decompress)', + '$OrcaScriptReader = New-Object System.IO.StreamReader -ArgumentList $OrcaScriptGzip, ([System.Text.Encoding]::UTF8)', + '$OrcaScriptText = $OrcaScriptReader.ReadToEnd()', + '$OrcaScriptReader.Dispose()', + 'Invoke-Expression $OrcaScriptText' + ].join('\n') +} + +/** Inverse of `powerShellCommand`: the script the host will actually run. */ +export function decodeRemotePowerShellScript(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/u)?.[1] + if (!encoded) { + return command + } + const script = Buffer.from(encoded, 'base64').toString('utf16le') + const payload = script.match( + /^\$OrcaScriptBytes = \[Convert\]::FromBase64String\('([A-Za-z0-9+/=]+)'\)/u + )?.[1] + return payload ? gunzipSync(Buffer.from(payload, 'base64')).toString('utf-8') : script +} diff --git a/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts b/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts new file mode 100644 index 00000000000..c104078238e --- /dev/null +++ b/src/main/ssh/ssh-remote-windows-command-line-limit.test.ts @@ -0,0 +1,78 @@ +import { gunzipSync } from 'node:zlib' +import { describe, expect, it } from 'vitest' +import { CMD_EXE_COMMAND_LINE_MAX_CHARS } from '../providers/windows-shell-args' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { tryStealInstallLockCommand } from './ssh-relay-install-lock-commands' +import { decodeRemotePowerShellScript, powerShellCommand } from './ssh-remote-powershell' +import { + cleanupOwnedRelayUploadStageCommand, + promoteOwnedRelayUploadStageCommand, + recoverOneStaleRelayUploadStageCommand, + reserveRelayUploadStageCommand, + type RelayUploadStageSlot +} from './ssh-relay-upload-stage-commands' + +const windows = getRemoteHostPlatform('win32-x64') +const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' +const pool = 'C:\\Users\\orca\\.orca-remote\\.upload-stages' +const stage: RelayUploadStageSlot = { + poolDir: pool, + slotName: 'slot-0', + slotDir: `${pool}\\slot-0`, + claimDir: `${pool}\\claim-0`, + deleteDir: `${pool}\\delete-0` +} + +// Why: sshd runs an exec request through its DefaultShell, which is cmd.exe on a +// stock Windows OpenSSH install, and cmd.exe refuses a longer line with exit 1 +// and a localized "The command line is too long" — the whole connect dies there. +describe('Windows remote command line limit', () => { + it.each([ + ['recover stale upload stage', recoverOneStaleRelayUploadStageCommand(windows, pool)], + ['reserve upload stage', reserveRelayUploadStageCommand(windows, pool, owner)], + [ + 'promote upload stage', + promoteOwnedRelayUploadStageCommand(windows, stage, owner, 'C:\\Users\\orca\\.orca-remote') + ], + ['cleanup upload stage', cleanupOwnedRelayUploadStageCommand(windows, stage, owner)], + [ + 'steal stale install lock', + tryStealInstallLockCommand(windows, 'C:\\Users\\orca\\.orca-remote\\relay', 1_200) + ] + ])('keeps the %s command inside what sshd\u2019s cmd.exe accepts', (_name, command) => { + expect(command.length).toBeLessThanOrEqual(CMD_EXE_COMMAND_LINE_MAX_CHARS) + }) + + it('leaves a command that already fits byte-identical', () => { + const script = "Write-Output ([Environment]::GetFolderPath('UserProfile'))" + expect(decodeRemotePowerShellScript(powerShellCommand(script))).toBe(script) + }) + + it('carries an oversized script through gzip without altering it', () => { + const script = Array.from( + { length: 200 }, + (_unused, index) => `Write-Output ${index}; $slot = 'C:\\Users\\orca\\stage-${index}'` + ).join('\n') + const command = powerShellCommand(script) + expect(command.length).toBeLessThanOrEqual(CMD_EXE_COMMAND_LINE_MAX_CHARS) + expect(decodeRemotePowerShellScript(command)).toBe(script) + const bootstrap = Buffer.from( + command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)$/u)?.[1] ?? '', + 'base64' + ).toString('utf16le') + const payload = bootstrap.match(/FromBase64String\('([A-Za-z0-9+/=]+)'\)/u)?.[1] ?? '' + expect(gunzipSync(Buffer.from(payload, 'base64')).toString('utf-8')).toBe(script) + expect(bootstrap).toContain('Invoke-Expression $OrcaScriptText') + }) + + it('refuses a script no encoding can fit instead of letting cmd.exe reject it', () => { + let seed = 12345 + const incompressible = Array.from({ length: 60_000 }, () => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return String.fromCharCode(97 + (seed % 26)) + }).join('') + expect(() => powerShellCommand(`Write-Output '${incompressible}'`)).toThrow( + /Orca budgets 8000 for a line sshd hands to cmd\.exe/u + ) + }) +}) diff --git a/src/main/startup/appimage-cli-redirect.test.ts b/src/main/startup/appimage-cli-redirect.test.ts deleted file mode 100644 index 99d5024a517..00000000000 --- a/src/main/startup/appimage-cli-redirect.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { getAppImageCliArgs, maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' - -const commandNames = ['serve', 'status', 'terminal'] - -describe('AppImage CLI redirect', () => { - it('detects direct AppImage CLI commands', () => { - expect( - getAppImageCliArgs( - ['orca-linux.AppImage', 'status', '--json'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['status', '--json']) - }) - - it('allows CLI global flags before the command', () => { - expect( - getAppImageCliArgs( - ['orca-linux.AppImage', '--pairing-code', 'abc123', '--json', 'terminal', 'list'], - { - APPIMAGE: '/opt/orca' - }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['--pairing-code', 'abc123', '--json', 'terminal', 'list']) - }) - - it('does not redirect normal desktop AppImage launches', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--no-sandbox', 'file:///tmp/example.txt'], - { - APPIMAGE: '/opt/orca' - }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toBeNull() - }) - - it('keeps direct serve launches in Electron when Chromium switches are present', () => { - expect( - getAppImageCliArgs( - [ - 'AppRun', - '--no-sandbox', - '--disable-features=FedCm,DirectSockets', - 'serve', - '--port', - '6768' - ], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toBeNull() - }) - - it('keeps clean serve launches on the CLI path for validation', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--no-sandbox', 'serve', '--port', '6768'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['serve', '--port', '6768']) - }) - - it('handles a space-separated Chromium switch value', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--disable-features', 'FedCm', 'serve'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toBeNull() - }) - - it('does not broaden the Electron-owned exception to switches after serve', () => { - expect( - getAppImageCliArgs( - ['AppRun', 'serve', '--disable-features=FedCm'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['serve', '--disable-features=FedCm']) - }) - - it('removes no-sandbox before forwarding CLI help', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--no-sandbox', 'serve', '--help'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['serve', '--help']) - }) - - it('still redirects serve help even when Chromium switches are present', () => { - expect( - getAppImageCliArgs( - ['AppRun', '--disable-features=FedCm', 'serve', '--help'], - { APPIMAGE: '/opt/orca' }, - { - platform: 'linux', - isPackaged: true, - commandNames - } - ) - ).toEqual(['--disable-features=FedCm', 'serve', '--help']) - }) - - it('spawns the unpacked CLI entrypoint with Electron node mode', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) - const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') - await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) - await writeFile(cliEntryPath, '', 'utf8') - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - - const result = maybeRedirectAppImageCliLaunch({ - argv: ['orca-linux.AppImage', 'status', '--json'], - env: { - APPIMAGE: '/opt/orca/orca-linux.AppImage', - NODE_OPTIONS: '--inspect', - NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' - }, - platform: 'linux', - isPackaged: true, - resourcesPath: root, - execPath: '/opt/orca/orca-ide', - commandNames, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 0 }) - expect(spawn).toHaveBeenCalledWith('/opt/orca/orca-ide', [cliEntryPath, 'status', '--json'], { - env: expect.objectContaining({ - APPIMAGE: '/opt/orca/orca-linux.AppImage', - ELECTRON_RUN_AS_NODE: '1', - ORCA_NODE_OPTIONS: '--inspect', - ORCA_NODE_REPL_EXTERNAL_MODULE: '/tmp/repl.js' - }), - stdio: 'inherit' - }) - const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined - expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') - expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') - }) - - it('keeps a clean no-sandbox serve launch on the CLI path', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-')) - const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js') - await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) - await writeFile(cliEntryPath, '', 'utf8') - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - - const result = maybeRedirectAppImageCliLaunch({ - argv: ['orca-linux.AppImage', '--no-sandbox', 'serve'], - env: { APPIMAGE: '/opt/orca/orca-linux.AppImage' }, - platform: 'linux', - isPackaged: true, - resourcesPath: root, - execPath: '/opt/orca/orca-ide', - commandNames, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 0 }) - expect(spawn).toHaveBeenCalledWith( - '/opt/orca/orca-ide', - [cliEntryPath, 'serve'], - expect.objectContaining({ - env: expect.objectContaining({ ORCA_APPIMAGE_NO_SANDBOX: '1' }) - }) - ) - }) -}) diff --git a/src/main/startup/appimage-cli-redirect.ts b/src/main/startup/appimage-cli-redirect.ts deleted file mode 100644 index 2ca5f54e155..00000000000 --- a/src/main/startup/appimage-cli-redirect.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from 'node:child_process' -import { existsSync } from 'node:fs' -import { join } from 'node:path' - -type RedirectResult = - | { - redirected: false - } - | { - redirected: true - status: number - } - -type RedirectOptions = { - argv?: string[] - env?: NodeJS.ProcessEnv - platform?: NodeJS.Platform - isPackaged?: boolean - resourcesPath?: string - execPath?: string - commandNames?: readonly string[] - spawn?: typeof spawnSync -} - -const HELP_FLAGS = new Set(['--help', '-h', 'help']) -const APPIMAGE_DESKTOP_FLAGS = new Set(['--no-sandbox']) -const ELECTRON_LAUNCH_SWITCHES = new Set(['--disable-features']) -const CLI_FLAGS_WITH_VALUES = new Set(['--environment', '--pairing-code', '--disable-features']) -// Why: the main tsconfig cannot import the CLI project, but AppImage direct -// launches need a conservative allow-list before bypassing the GUI startup. -const APPIMAGE_CLI_COMMAND_NAMES = [ - 'agent', - 'automations', - 'back', - 'capture', - 'check', - 'clear', - 'click', - 'clipboard', - 'computer', - 'console', - 'cookie', - 'dblclick', - 'dialog', - 'download', - 'drag', - 'environment', - 'eval', - 'exec', - 'file', - 'fill', - 'find', - 'focus', - 'forward', - 'full-screenshot', - 'geolocation', - 'get', - 'goto', - 'highlight', - 'hover', - 'inserttext', - 'intercept', - 'is', - 'keypress', - 'mouse', - 'network', - 'open', - 'orchestration', - 'pdf', - 'reload', - 'repo', - 'screenshot', - 'scroll', - 'scrollintoview', - 'select', - 'select-all', - 'serve', - 'set', - 'snapshot', - 'status', - 'storage', - 'tab', - 'terminal', - 'type', - 'uncheck', - 'upload', - 'viewport', - 'wait', - 'worktree' -] - -export function maybeRedirectAppImageCliLaunch(options: RedirectOptions = {}): RedirectResult { - const argv = options.argv ?? process.argv - const env = options.env ?? process.env - const platform = options.platform ?? process.platform - const isPackaged = options.isPackaged ?? false - const resourcesPath = options.resourcesPath ?? process.resourcesPath - const execPath = options.execPath ?? process.execPath - const spawn = options.spawn ?? spawnSync - const cliArgs = getAppImageCliArgs(argv, env, { - platform, - isPackaged, - commandNames: options.commandNames ?? APPIMAGE_CLI_COMMAND_NAMES - }) - - if (!cliArgs) { - return { redirected: false } - } - - const cliEntryPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - if (!existsSync(cliEntryPath)) { - process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) - return { redirected: true, status: 1 } - } - - const childEnv = buildElectronRunAsNodeEnv(env) - if (argv.slice(1).includes('--no-sandbox')) { - // Why: the operator explicitly disabled Chromium's sandbox; preserve that choice when `serve` launches the Electron child. - childEnv.ORCA_APPIMAGE_NO_SANDBOX = '1' - } - const result = spawn(execPath, [cliEntryPath, ...cliArgs], { - env: childEnv, - stdio: 'inherit' - }) as SpawnSyncReturns - - if (result.error) { - process.stderr.write(`${result.error.message}\n`) - return { redirected: true, status: 1 } - } - - return { redirected: true, status: result.status ?? 1 } -} - -export function getAppImageCliArgs( - argv: string[], - env: NodeJS.ProcessEnv, - options: { - platform: NodeJS.Platform - isPackaged: boolean - commandNames: readonly string[] - } -): string[] | null { - if (options.platform !== 'linux' || !options.isPackaged) { - return null - } - if (!env.APPIMAGE && !env.APPDIR) { - return null - } - - const args = argv.slice(1) - if (args.length === 0) { - return null - } - const cliArgs = args.filter((arg) => !APPIMAGE_DESKTOP_FLAGS.has(arg)) - if (cliArgs.some((arg) => HELP_FLAGS.has(arg))) { - return cliArgs - } - - const commandNames = new Set(options.commandNames) - const firstPositional = findFirstCommandCandidate(cliArgs) - if (!firstPositional || !commandNames.has(firstPositional)) { - return null - } - // Keep serve in Electron only when an Electron launch switch is present. - // Forwarding it to the strict Node-mode CLI parser makes an otherwise valid - // serve launch fail, while clean serve invocations retain CLI validation. - if ( - firstPositional === 'serve' && - cliArgs - .slice(0, findFirstCommandCandidateIndex(cliArgs)) - .some((arg) => ELECTRON_LAUNCH_SWITCHES.has(flagName(arg))) - ) { - return null - } - return cliArgs -} - -function findFirstCommandCandidate(args: string[]): string | null { - const index = findFirstCommandCandidateIndex(args) - return index === -1 ? null : args[index]! -} - -function findFirstCommandCandidateIndex(args: string[]): number { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] - if (!arg.startsWith('-')) { - return index - } - if (CLI_FLAGS_WITH_VALUES.has(flagName(arg)) && !arg.includes('=')) { - index += 1 - } - } - return -1 -} - -function flagName(arg: string): string { - const equalsIndex = arg.indexOf('=') - return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex) -} - -function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const childEnv = { ...env } - childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' - childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' - childEnv.ELECTRON_RUN_AS_NODE = '1' - delete childEnv.NODE_OPTIONS - delete childEnv.NODE_REPL_EXTERNAL_MODULE - return childEnv -} diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts new file mode 100644 index 00000000000..2f1b0e4394d --- /dev/null +++ b/src/main/startup/cli-command-names.ts @@ -0,0 +1,73 @@ +// Kept import-free for main/CLI project isolation; a parity test prevents drift. +export const CLI_COMMAND_NAMES = [ + 'account', + 'agent', + 'agent-context', + 'artifacts', + 'automations', + 'back', + 'capture', + 'check', + 'claude-teams', + 'clear', + 'click', + 'clipboard', + 'computer', + 'console', + 'cookie', + 'dblclick', + 'diagnostics', + 'dialog', + 'download', + 'drag', + 'emulator', + 'environment', + 'eval', + 'exec', + 'file', + 'fill', + 'find', + 'focus', + 'forward', + 'full-screenshot', + 'geolocation', + 'get', + 'goto', + 'highlight', + 'host', + 'hover', + 'inserttext', + 'intercept', + 'is', + 'keypress', + 'linear', + 'mouse', + 'network', + 'open', + 'open-url', + 'orchestration', + 'pdf', + 'project', + 'reload', + 'repo', + 'screenshot', + 'scroll', + 'scrollintoview', + 'select', + 'select-all', + 'serve', + 'set', + 'skills', + 'snapshot', + 'status', + 'storage', + 'tab', + 'terminal', + 'type', + 'uncheck', + 'upload', + 'viewport', + 'vm', + 'wait', + 'worktree' +] as const diff --git a/src/main/startup/cli-launch-redirect.test.ts b/src/main/startup/cli-launch-redirect.test.ts new file mode 100644 index 00000000000..7a4b29fe60e --- /dev/null +++ b/src/main/startup/cli-launch-redirect.test.ts @@ -0,0 +1,357 @@ +import { posix, win32 } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { getCliLaunchArgs, maybeRedirectCliLaunch } from './cli-launch-redirect' + +const COMMAND_NAMES = ['project', 'serve', 'status', 'skills', 'worktree'] + +const linux = { + resourcesPath: '/opt/Orca/resources', + execPath: '/opt/Orca/orca-ide', + get cliEntryPath(): string { + return posix.join(this.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + } +} +const windows = { + resourcesPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources', + execPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\Orca.exe', + get cliEntryPath(): string { + return win32.join(this.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + } +} + +const linuxOptions = { platform: 'linux' as const, isPackaged: true, commandNames: COMMAND_NAMES } +const windowsOptions = { platform: 'win32' as const, isPackaged: true, commandNames: COMMAND_NAMES } + +describe('CLI launch redirect: entry-path form', () => { + it('detects a launch that received the unpacked CLI entrypoint', () => { + expect( + getCliLaunchArgs( + [windows.execPath, windows.cliEntryPath.toUpperCase(), 'status', '--json'], + windows.cliEntryPath, + windowsOptions + ) + ).toEqual(['status', '--json']) + }) + + it('ignores normal desktop launches', () => { + expect( + getCliLaunchArgs([windows.execPath, '--updated'], windows.cliEntryPath, windowsOptions) + ).toBeNull() + }) + + it('ignores the entrypoint when it is only the executable itself (argv[0])', () => { + expect( + getCliLaunchArgs([windows.cliEntryPath, 'status'], windows.cliEntryPath, windowsOptions) + ).toBeNull() + }) + + it('applies on Linux too', () => { + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, 'status'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status']) + }) + + it('strips injected Chromium switches before node-mode CLI arguments', () => { + expect( + getCliLaunchArgs( + [ + linux.execPath, + linux.cliEntryPath, + '--no-sandbox', + '--disable-gpu', + '--disable-features=Vulkan', + 'status', + '--json' + ], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status', '--json']) + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, '--disable-features', 'Vulkan', 'skills', 'get'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['skills', 'get']) + }) + + it('keeps user flags after the command and malformed boolean assignments', () => { + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, 'status', '--disable-features=Vulkan'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status', '--disable-features=Vulkan']) + expect( + getCliLaunchArgs( + [linux.execPath, linux.cliEntryPath, '--no-sandbox=true', 'status'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['--no-sandbox=true', 'status']) + }) + + it('does not treat a later positional entrypoint path as the launcher', () => { + expect( + getCliLaunchArgs( + [linux.execPath, 'file', 'open', '--path', linux.cliEntryPath], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + }) +}) + +describe('CLI launch redirect: command form', () => { + it('redirects a direct binary launch with no AppImage env at all', () => { + expect( + getCliLaunchArgs( + ['/home/u/.config/orca-runtime/versions/1.4.158/orca-ide', 'skills', 'get', '--full'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['skills', 'get', '--full']) + }) + + it('strips Chromium switches node mode would reject', () => { + expect( + getCliLaunchArgs( + [linux.execPath, '--no-sandbox', '--disable-gpu', 'status', '--json'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['status', '--json']) + }) + + it('preserves desktop-shaped switches after the command', () => { + expect( + getCliLaunchArgs( + [linux.execPath, 'skills', 'get', '--disable-gpu', '--no-sandbox'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['skills', 'get', '--disable-gpu', '--no-sandbox']) + }) + + it('leaves direct serve in-process but redirects its help', () => { + expect( + getCliLaunchArgs( + [linux.execPath, '--no-sandbox', 'serve', '--port', '6768'], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + expect( + getCliLaunchArgs( + [linux.execPath, '--no-sandbox', 'serve', '--help'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['serve', '--help']) + expect( + getCliLaunchArgs( + [linux.execPath, '--disable-features', 'Vulkan', 'serve', '--help'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['serve', '--help']) + }) + + it('treats help as a CLI launch even without a command', () => { + expect(getCliLaunchArgs([linux.execPath, '--help'], linux.cliEntryPath, linuxOptions)).toEqual([ + '--help' + ]) + }) + + it.each(['--version', '-v'])('treats %s as a CLI launch even without a command', (flag) => { + expect(getCliLaunchArgs([linux.execPath, flag], linux.cliEntryPath, linuxOptions)).toEqual([ + flag + ]) + }) + + it.each(['--user-data-dir', '--proxy-server', '--unknown-desktop-switch'])( + 'does not treat a value of %s as a CLI early-exit flag', + (flag) => { + expect( + getCliLaunchArgs([linux.execPath, flag, 'help'], linux.cliEntryPath, linuxOptions) + ).toBeNull() + } + ) + + it('does not treat a serve option value as a help request', () => { + expect( + getCliLaunchArgs( + [linux.execPath, 'serve', '--project-root', 'help'], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + }) + + it('does not reinterpret help after the argument terminator', () => { + expect( + getCliLaunchArgs([linux.execPath, 'serve', '--', '--help'], linux.cliEntryPath, linuxOptions) + ).toBeNull() + }) + + it('leaves a plain desktop launch alone', () => { + expect(getCliLaunchArgs([linux.execPath], linux.cliEntryPath, linuxOptions)).toBeNull() + expect( + getCliLaunchArgs([linux.execPath, '/home/u/project'], linux.cliEntryPath, linuxOptions) + ).toBeNull() + }) + + it('skips flag values when looking for the command positional', () => { + expect( + getCliLaunchArgs( + [linux.execPath, '--environment', 'status', 'worktree', 'ps'], + linux.cliEntryPath, + linuxOptions + ) + ).toEqual(['--environment', 'status', 'worktree', 'ps']) + expect( + getCliLaunchArgs( + [linux.execPath, '--environment', 'status'], + linux.cliEntryPath, + linuxOptions + ) + ).toBeNull() + }) + + it.each([ + ['--project', 'github:stablyai/orca', 'project', 'setups'], + ['--project=github:stablyai/orca', 'project', 'setups'], + ['--project', 'project', 'project', 'setups'], + ['--project=project', 'project', 'setups'] + ])('preserves a project selector in %j', (...args) => { + expect(getCliLaunchArgs([linux.execPath, ...args], linux.cliEntryPath, linuxOptions)).toEqual( + args + ) + }) + + it('does not apply the command form on macOS or Windows', () => { + for (const platform of ['darwin', 'win32'] as const) { + expect( + getCliLaunchArgs([linux.execPath, 'status'], linux.cliEntryPath, { + platform, + isPackaged: true, + commandNames: COMMAND_NAMES + }) + ).toBeNull() + } + }) + + it('never redirects an unpackaged build', () => { + expect( + getCliLaunchArgs([linux.execPath, 'status'], linux.cliEntryPath, { + ...linuxOptions, + isPackaged: false + }) + ).toBeNull() + }) +}) + +describe('CLI launch redirect: spawning', () => { + it('runs the in-package CLI in Electron node mode with sanitized env', () => { + const run = vi.fn((..._args: unknown[]) => ({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + })) + + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status', '--json'], + env: { NODE_OPTIONS: '--inspect', NODE_REPL_EXTERNAL_MODULE: 'external-loader' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run: run as never + }) + + expect(result).toEqual({ redirected: true, status: 0 }) + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + program: linux.execPath, + args: [linux.cliEntryPath, 'status', '--json'], + stdio: 'inherit', + timeoutMs: null, + env: expect.objectContaining({ + ELECTRON_RUN_AS_NODE: '1', + ORCA_CLI_LAUNCH_REDIRECTED: '1', + ORCA_NODE_OPTIONS: '--inspect', + ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader' + }) + }) + ) + const spawnedEnv = (run.mock.calls[0][0] as { env: NodeJS.ProcessEnv }).env + expect(spawnedEnv).not.toHaveProperty('NODE_OPTIONS') + expect(spawnedEnv).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') + }) + + it('refuses to redirect twice so a dropped ELECTRON_RUN_AS_NODE cannot loop', () => { + const run = vi.fn() + + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status'], + env: { ORCA_CLI_LAUNCH_REDIRECTED: '1' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run: run as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + expect(run).not.toHaveBeenCalled() + }) + + it('reports a missing CLI entrypoint instead of booting the desktop app', () => { + const run = vi.fn() + + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status'], + env: {}, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => false, + run: run as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + expect(run).not.toHaveBeenCalled() + }) + + it('surfaces a spawn failure as a non-zero exit', () => { + const result = maybeRedirectCliLaunch({ + argv: [linux.execPath, 'status'], + env: {}, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run: (() => { + throw new Error('spawn ENOENT') + }) as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + }) +}) diff --git a/src/main/startup/cli-launch-redirect.ts b/src/main/startup/cli-launch-redirect.ts new file mode 100644 index 00000000000..88c0e8062cb --- /dev/null +++ b/src/main/startup/cli-launch-redirect.ts @@ -0,0 +1,245 @@ +import { existsSync } from 'node:fs' +import { posix, win32 } from 'node:path' +import { runProcessSync } from '../../shared/child-process/run-process' +import { CLI_BOOLEAN_FLAGS, findCliCommandIndex } from '../../shared/cli-argument-boundary' +import { CLI_COMMAND_NAMES } from './cli-command-names' +import { VALUE_TAKING_FLAGS } from './serve-mode-argv' + +export type CliLaunchRedirectResult = { redirected: false } | { redirected: true; status: number } + +export type CliLaunchRedirectOptions = { + argv?: string[] + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + isPackaged?: boolean + resourcesPath?: string + execPath?: string + commandNames?: readonly string[] + exists?: typeof existsSync + run?: typeof runProcessSync +} + +const CLI_EARLY_EXIT_FLAGS = new Set(['--help', '-h', 'help', '--version', '-v']) +const DESKTOP_FLAGS = new Set(['--no-sandbox', '--disable-gpu']) +const DESKTOP_VALUE_FLAGS = new Set(['--disable-features']) +const CLI_LAUNCH_VALUE_FLAG_NAMES = [...VALUE_TAKING_FLAGS].map((flag) => flag.slice(2)) + +// Fence recursion if a wrapper drops ELECTRON_RUN_AS_NODE again. +const REDIRECT_ATTEMPT_ENV = 'ORCA_CLI_LAUNCH_REDIRECTED' + +// Redirect packaged CLI-shaped launches before Chromium initializes. +export function maybeRedirectCliLaunch( + options: CliLaunchRedirectOptions = {} +): CliLaunchRedirectResult { + const argv = options.argv ?? process.argv + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const isPackaged = options.isPackaged ?? false + const resourcesPath = options.resourcesPath ?? process.resourcesPath + const execPath = options.execPath ?? process.execPath + const exists = options.exists ?? existsSync + const run = options.run ?? runProcessSync + const cliEntryPath = buildPackagedCliEntryPath(platform, resourcesPath) + const cliArgs = getCliLaunchArgs(argv, cliEntryPath, { + platform, + isPackaged, + commandNames: options.commandNames ?? CLI_COMMAND_NAMES + }) + + if (!cliArgs) { + return { redirected: false } + } + if (env[REDIRECT_ATTEMPT_ENV] === '1') { + process.stderr.write('Unable to start the Orca CLI through Electron node mode.\n') + return { redirected: true, status: 1 } + } + if (!exists(cliEntryPath)) { + process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) + return { redirected: true, status: 1 } + } + + const childEnv = buildElectronRunAsNodeEnv(env) + try { + const result = run({ + program: execPath, + args: [cliEntryPath, ...cliArgs], + env: childEnv, + stdio: 'inherit', + timeoutMs: null + }) + return { redirected: true, status: result.code ?? 1 } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + return { redirected: true, status: 1 } + } +} + +export function getCliLaunchArgs( + argv: string[], + cliEntryPath: string, + options: { + platform: NodeJS.Platform + isPackaged: boolean + commandNames: readonly string[] + } +): string[] | null { + if (!options.isPackaged) { + return null + } + return getEntryPathLaunchArgs(argv, cliEntryPath, options) ?? getCommandLaunchArgs(argv, options) +} + +function getEntryPathLaunchArgs( + argv: string[], + cliEntryPath: string, + options: { platform: NodeJS.Platform; commandNames: readonly string[] } +): string[] | null { + const expectedCliPath = normalizePathForPlatform(cliEntryPath, options.platform) + // The packaged launcher always passes the entrypoint as Electron's first argument. + // Matching later positional arguments can mistake a normal desktop launch for the CLI. + if (!argv[1] || normalizePathForPlatform(argv[1], options.platform) !== expectedCliPath) { + return null + } + const args = argv.slice(2) + return stripDesktopFlags(args, findCommandIndex(args, options.commandNames)) +} + +function getCommandLaunchArgs( + argv: string[], + options: { platform: NodeJS.Platform; commandNames: readonly string[] } +): string[] | null { + if (options.platform !== 'linux') { + return null + } + const args = argv.slice(1) + if (args.length === 0) { + return null + } + const commandIndex = findCommandIndex(args, options.commandNames) + const cliArgs = stripDesktopFlags(args, commandIndex) + const command = commandIndex === -1 ? null : args[commandIndex] + // Keep direct serve in-process so signals reach its full child tree. + if (command && command !== 'serve') { + return cliArgs + } + return hasCliEarlyExitArg(args, commandIndex) ? cliArgs : null +} + +function findCommandIndex(args: readonly string[], commandNames: readonly string[]): number { + return findCliCommandIndex( + args, + commandNames.map((name) => [name]), + CLI_LAUNCH_VALUE_FLAG_NAMES + ) +} + +function stripDesktopFlags(args: readonly string[], commandIndex: number): string[] { + const boundary = commandIndex === -1 ? findLeadingFlagBoundary(args) : commandIndex + const cliArgs: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]! + if (index < boundary) { + if (DESKTOP_FLAGS.has(arg)) { + continue + } + if (DESKTOP_VALUE_FLAGS.has(flagName(arg))) { + if (!arg.includes('=') && args[index + 1] && !args[index + 1]!.startsWith('-')) { + index += 1 + } + continue + } + } + cliArgs.push(arg) + } + return cliArgs +} + +function findLeadingFlagBoundary(args: readonly string[]): number { + let index = 0 + while (index < args.length) { + const token = args[index]! + if (token === '--' || !token.startsWith('-')) { + return index + } + index += 1 + if (takesLaunchValue(token, args[index])) { + index += 1 + } + } + return index +} + +function hasCliEarlyExitArg(args: readonly string[], commandIndex: number): boolean { + let index = 0 + let positionalCount = 0 + while (index < args.length) { + const token = args[index]! + if (token === '--') { + return false + } + if ( + CLI_EARLY_EXIT_FLAGS.has(token) && + (token !== 'help' || positionalCount === 0 || commandIndex !== -1) + ) { + return true + } + if (!token.startsWith('-')) { + if (token === 'help' && (positionalCount === 0 || commandIndex !== -1)) { + return true + } + positionalCount += 1 + } + index += 1 + if (takesLaunchValue(token, args[index])) { + index += 1 + } + } + return false +} + +function takesLaunchValue(token: string, next: string | undefined): boolean { + if ( + !next || + next.startsWith('-') || + !token.startsWith('-') || + token.includes('=') || + CLI_EARLY_EXIT_FLAGS.has(token) || + DESKTOP_FLAGS.has(token) + ) { + return false + } + const name = flagName(token) + return DESKTOP_VALUE_FLAGS.has(name) || !CLI_BOOLEAN_FLAGS.has(name.replace(/^-+/, '')) +} + +function flagName(arg: string): string { + const equalsIndex = arg.indexOf('=') + return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex) +} + +function buildPackagedCliEntryPath(platform: NodeJS.Platform, resourcesPath: string): string { + return getPathApi(platform).join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') +} + +function normalizePathForPlatform(value: string, platform: NodeJS.Platform): string { + const pathApi = getPathApi(platform) + const normalized = pathApi.normalize(pathApi.isAbsolute(value) ? value : pathApi.resolve(value)) + // Windows path comparisons are case-insensitive. + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getPathApi(platform: NodeJS.Platform): typeof win32 | typeof posix { + return platform === 'win32' ? win32 : posix +} + +function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const childEnv = { ...env } + // Preserve user values without exposing them to Electron's bootstrap. + childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' + childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' + childEnv.ELECTRON_RUN_AS_NODE = '1' + childEnv[REDIRECT_ATTEMPT_ENV] = '1' + delete childEnv.NODE_OPTIONS + delete childEnv.NODE_REPL_EXTERNAL_MODULE + return childEnv +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index e432ed383d2..fc381f15714 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -183,6 +183,48 @@ describe('startup ordering', () => { ) }) + it('keeps the git-environment barrier off the PTY startup services', () => { + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' + ) + const launchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const gitBarrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'" + ) + const gitBarrierEnd = barrierSource.indexOf( + "'app:prepareTerminalStartupRestoration'", + gitBarrierStart + ) + expect(gitBarrierStart).toBeGreaterThanOrEqual(0) + expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart) + const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd) + // The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a + // hook-server bind here puts terminal startup back in front of worktree hydration. + expect(gitBarrier).toContain('state.shellPathReady') + expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady') + expect(gitBarrier).not.toContain('firstWindowStartupServicesReady') + // The published promise must be the same one the terminal startup services wait on. + expect(launchSource).toContain('state.shellPathReady = shellPathReady') + expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan( + launchSource.indexOf('await launchDesktopMode(') + ) + // Terminal restoration itself must still fence on the first-window services. + const restorationStart = barrierSource.indexOf( + "ipcMain.handle('app:prepareTerminalStartupRestoration'" + ) + const restorationEnd = barrierSource.indexOf( + "'app:recoverLegacyWorkerTerminalsForRendererStartup'", + restorationStart + ) + expect(barrierSource.slice(restorationStart, restorationEnd)).toContain( + 'state.firstWindowStartupServicesReady' + ) + }) + it('reconciles retained Codex homes after authoritative daemon inventory', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index 93f4bb14f77..ded8360bce5 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -1,24 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } = +const { spawnMock, existsSyncMock, readFileSyncMock, rmSyncMock, statSyncMock, appMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), - spawnSyncMock: vi.fn(), existsSyncMock: vi.fn(), readFileSyncMock: vi.fn(), rmSyncMock: vi.fn(), + statSyncMock: vi.fn(), appMock: { disableHardwareAcceleration: vi.fn(), - commandLine: { appendSwitch: vi.fn() }, + commandLine: { appendSwitch: vi.fn(), getSwitchValue: vi.fn() }, once: vi.fn() } })) -vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock })) +vi.mock('child_process', () => ({ spawn: spawnMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, readFileSync: readFileSyncMock, - rmSync: rmSyncMock + rmSync: rmSyncMock, + statSync: statSyncMock })) vi.mock('electron', () => ({ app: appMock })) @@ -29,15 +30,39 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform, configurable: true }) } +function mockLiveXDisplay(pid = 4321): void { + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(`${pid}\n`) + vi.spyOn(process, 'kill').mockImplementation(() => true) +} + +function mockXvfbTakesDisplay(pid = 1234): void { + let bound = false + statSyncMock.mockImplementation(() => ({ isSocket: () => true })) + readFileSyncMock.mockImplementation(() => { + if (!bound) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return `${pid}\n` + }) + vi.spyOn(process, 'kill').mockImplementation(() => true) + spawnMock.mockImplementation(() => { + bound = true + return { pid, once: vi.fn(), kill: vi.fn(), killed: false } + }) +} + describe('ensureVirtualDisplayForHeadlessServe', () => { beforeEach(() => { spawnMock.mockReset() - spawnSyncMock.mockReset() existsSyncMock.mockReset() readFileSyncMock.mockReset() rmSyncMock.mockReset() + statSyncMock.mockReset() appMock.disableHardwareAcceleration.mockReset() appMock.commandLine.appendSwitch.mockReset() + appMock.commandLine.getSwitchValue.mockReset().mockReturnValue('') appMock.once.mockReset() delete process.env.DISPLAY }) @@ -76,6 +101,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { it('reuses an externally provided DISPLAY without starting Xvfb', async () => { setPlatform('linux') process.env.DISPLAY = ':0' + mockLiveXDisplay() const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) @@ -86,48 +112,75 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') }) - it('reports unsupported (no spawn) when Xvfb is not installed', async () => { + it('reports unsupported when Xvfb cannot be launched', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 1 }) // `which Xvfb` fails + spawnMock.mockReturnValue({ pid: undefined, once: vi.fn(), kill: vi.fn(), killed: false }) + const { ensureVirtualDisplayForHeadlessServe, MISSING_LINUX_DISPLAY_MESSAGE } = + await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(spawnMock).toHaveBeenCalledWith('Xvfb', expect.any(Array), expect.any(Object)) + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('endpoint is unavailable') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('XDG_RUNTIME_DIR') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xvfb` on Debian/Ubuntu') + expect(MISSING_LINUX_DISPLAY_MESSAGE).toContain('`xorg-x11-server-Xvfb`') + }) + + it('leaves an externally configured stale display untouched', async () => { + setPlatform('linux') + process.env.DISPLAY = ':77' + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementation(() => { + throw new Error('display lock is outside this namespace') + }) const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) expect(spawnMock).not.toHaveBeenCalled() + expect(rmSyncMock).not.toHaveBeenCalled() + expect(process.env.DISPLAY).toBe(':77') }) - it('starts Xvfb and switches to software rendering when none exists', async () => { + // #15084 review: a container that bind-mounts only /tmp/.X11-unix used to serve and would + // otherwise now exit(1) at index.ts, since the serve gate treats false as fatal. + it('serves on an externally configured display that has no lock file', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 0 }) // `which Xvfb` succeeds - // First existsSync (stale-socket check) false; later (socket-ready poll) true. - existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true) - spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false }) - const processOnceSpy = vi.spyOn(process, 'once') - const processRemoveListenerSpy = vi.spyOn(process, 'removeListener') - const { ensureVirtualDisplayForHeadlessServe, stopVirtualDisplay } = - await import('./ensure-virtual-display') + process.env.DISPLAY = ':0' + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(spawnMock).not.toHaveBeenCalled() + expect(rmSyncMock).not.toHaveBeenCalled() + expect(process.env.DISPLAY).toBe(':0') + }) + + // removeStaleDisplayArtifacts unlinks the lock before the socket, so a crash between the two + // leaves a lockless socket on Orca's OWN :99. Adopting it would resurrect the orphan-socket bug. + it('does not adopt its own :99 socket when the lock is missing', async () => { + setPlatform('linux') + existsSyncMock.mockReturnValue(true) + mockXvfbTakesDisplay() + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + // Cleaned up and respawned rather than trusted. + expect(rmSyncMock).toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledWith( 'Xvfb', - expect.arrayContaining([':99', '-terminate']), - expect.objectContaining({ detached: true }) + expect.arrayContaining([':99']), + expect.any(Object) ) - expect(process.env.DISPLAY).toBe(':99') - expect(appMock.disableHardwareAcceleration).toHaveBeenCalled() - expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') - expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') - expect(processOnceSpy).toHaveBeenCalledWith('exit', stopVirtualDisplay) - const readyHandler = appMock.once.mock.calls.find(([event]) => event === 'ready')?.[1] - expect(readyHandler).toBeTypeOf('function') - readyHandler() - expect(processRemoveListenerSpy).toHaveBeenCalledWith('exit', stopVirtualDisplay) - expect(appMock.once.mock.calls.some(([event]) => event === 'will-quit')).toBe(false) }) it('reuses an existing virtual display only when its X server is alive', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 0 }) - existsSyncMock.mockReturnValue(true) // :99 socket + lock present + statSyncMock.mockReturnValue({ isSocket: () => true }) // :99 socket present + existsSyncMock.mockReturnValue(true) readFileSyncMock.mockReturnValue('4321\n') // lock holds a PID const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never) // PID alive const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') @@ -142,14 +195,21 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { it('treats a stale socket (dead server) as no display and starts a fresh Xvfb', async () => { setPlatform('linux') - spawnSyncMock.mockReturnValue({ status: 0 }) - existsSyncMock.mockReturnValue(true) // orphan socket + lock present - readFileSyncMock.mockReturnValue('9999\n') - // PID is gone: process.kill throws ESRCH. - const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw new Error('ESRCH') + existsSyncMock.mockReturnValue(true) // lock present + let bound = false + statSyncMock.mockImplementation(() => ({ isSocket: () => true })) + readFileSyncMock.mockImplementation(() => (bound ? '1234\n' : '9999\n')) + // The orphan lock names a dead PID; the freshly spawned Xvfb is alive. + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 9999) { + throw new Error('ESRCH') + } + return true as never + }) + spawnMock.mockImplementation(() => { + bound = true + return { pid: 1234, once: vi.fn(), kill: vi.fn(), killed: false } }) - spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false }) const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) @@ -163,4 +223,278 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { expect(process.env.DISPLAY).toBe(':99') killSpy.mockRestore() }) + + // A root-owned stale :99 socket (crashed system Xvfb, serve running as User=orca) cannot be + // unlinked, so our Xvfb refuses to bind and exits. Trusting the surviving socket set DISPLAY to a + // dead server and Chromium died in Ozone init with SIGSEGV. + it('reports failure when a stale socket blocks the Xvfb rebind', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + // Removal fails (foreign owner) and no lock ever appears, because Xvfb never took the display. + rmSyncMock.mockImplementation(() => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + spawnMock.mockReturnValue({ pid: 4242, once: vi.fn(), kill: vi.fn(), killed: false }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(process.env.DISPLAY).toBeUndefined() + }) + + it('accepts the display once the spawned Xvfb owns its lock', async () => { + setPlatform('linux') + let lockWritten = false + statSyncMock.mockImplementation(() => ({ isSocket: () => lockWritten })) + rmSyncMock.mockImplementation(() => {}) + readFileSyncMock.mockImplementation(() => { + if (!lockWritten) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return '4242\n' + }) + vi.spyOn(process, 'kill').mockImplementation(() => true) + spawnMock.mockImplementation(() => { + lockWritten = true + return { pid: 4242, once: vi.fn(), kill: vi.fn(), killed: false } + }) + const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') + + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true) + expect(process.env.DISPLAY).toBe(':99') + }) + + describe('hasUsableLinuxDisplay', () => { + it('accepts live local X11 and Wayland sockets', async () => { + setPlatform('linux') + mockLiveXDisplay() + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + expect( + hasUsableLinuxDisplay({ + WAYLAND_DISPLAY: 'wayland-0', + XDG_RUNTIME_DIR: '/run/user/1000' + }) + ).toBe(true) + expect(statSyncMock).toHaveBeenCalledWith('/tmp/.X11-unix/X0') + expect(statSyncMock).toHaveBeenCalledWith('/run/user/1000/wayland-0') + }) + + // An X server may bind only the abstract namespace, leaving nothing to stat. Abstract addresses + // are kernel-owned and vanish when the owner exits, so an entry is proof of a live server. + it('accepts an abstract-namespace X socket with no filesystem socket', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileSyncMock.mockImplementation((path: string) => { + if (path === '/proc/net/unix') { + return [ + 'Num RefCount Protocol Flags Type St Inode Path', + '0000000000000000: 00000003 00000000 00000000 0001 03 12014 @/tmp/.X11-unix/X0', + '' + ].join('\n') + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('does not confuse a different display number in the abstract table', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileSyncMock.mockImplementation((path: string) => { + if (path === '/proc/net/unix') { + return '0000000000000000: 00000003 00000000 00000000 0001 03 12014 @/tmp/.X11-unix/X10\n' + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':1' })).toBe(false) + }) + + it('accepts an inherited WAYLAND_SOCKET fd with no WAYLAND_DISPLAY', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ WAYLAND_SOCKET: '7' })).toBe(true) + expect(hasUsableLinuxDisplay({ WAYLAND_SOCKET: 'not-an-fd' })).toBe(false) + }) + + // Orca's own teardown unlinks the lock before the socket, so a lockless :99 is our own + // half-finished cleanup — trusting it because DISPLAY names it would accept a dead display. + it('does not trust a lockless socket on its own managed display number', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':99' })).toBe(false) + // A foreign display number with the same shape is still accepted. + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('rejects an orphaned local X11 socket whose server PID is gone', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue('9999\n') + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false) + expect(readFileSyncMock).toHaveBeenCalledWith('/tmp/.X77-lock', 'utf8') + }) + + // An X server writes its lock beside the socket and both survive a crash, so a lockless + // socket is an endpoint published from elsewhere (container bind mount, WSLg) — not an orphan. + it('accepts a local X11 socket published without a lock file', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const killSpy = vi.spyOn(process, 'kill') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + expect(killSpy).not.toHaveBeenCalled() + }) + + // WSLg with ELECTRON_OZONE_PLATFORM_HINT=x11 has no Wayland fallback to rescue it. + it('accepts a lockless X11 socket when x11 is pinned and Wayland is unavailable', async () => { + setPlatform('linux') + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/tmp/.X11-unix/X0' + })) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0', ELECTRON_OZONE_PLATFORM_HINT: 'x11' })).toBe( + true + ) + }) + + it('still rejects a missing socket even when no lock file exists', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => false }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(false) + }) + + it('rejects a lock that exists but cannot be read', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(false) + }) + + it('accepts a live local X11 server owned by another user', async () => { + setPlatform('linux') + statSyncMock.mockReturnValue({ isSocket: () => true }) + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue('4321\n') + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not permitted'), { code: 'EPERM' }) + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: ':0' })).toBe(true) + }) + + it('rejects absent, blank, and stale local displays', async () => { + setPlatform('linux') + statSyncMock.mockImplementation(() => { + throw new Error('ENOENT') + }) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({})).toBe(false) + expect(hasUsableLinuxDisplay({ DISPLAY: ' ', WAYLAND_DISPLAY: '' })).toBe(false) + expect(hasUsableLinuxDisplay({ DISPLAY: ':77' })).toBe(false) + expect( + hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0', XDG_RUNTIME_DIR: '/run/user/1000' }) + ).toBe(false) + expect(hasUsableLinuxDisplay({ WAYLAND_DISPLAY: 'wayland-0' })).toBe(false) + }) + + it.each([ + ['localhost:10.0', true], + ['build-host.example:1', true], + ['[2001:db8::1]:2.0', true], + ['tcp/build-host.example:3', true], + ['garbage', false], + ['build host:1', false], + ['build-host.example:', false], + ['build-host.example:abc', false] + ])('validates remote X display syntax for %s', async (display, expected) => { + setPlatform('linux') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({ DISPLAY: display })).toBe(expected) + expect(statSyncMock).not.toHaveBeenCalled() + }) + + it('honors forced X11 and Wayland platform selection', async () => { + setPlatform('linux') + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/run/user/1000/wayland-0' + })) + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + const env = { + DISPLAY: ':77', + WAYLAND_DISPLAY: 'wayland-0', + XDG_RUNTIME_DIR: '/run/user/1000' + } + + appMock.commandLine.getSwitchValue.mockReturnValue('x11') + expect(hasUsableLinuxDisplay(env)).toBe(false) + appMock.commandLine.getSwitchValue.mockReturnValue('wayland') + expect(hasUsableLinuxDisplay(env)).toBe(true) + + statSyncMock.mockImplementation((path: string) => ({ + isSocket: () => path === '/tmp/.X11-unix/X0' + })) + expect(hasUsableLinuxDisplay({ ...env, DISPLAY: ':0' })).toBe(false) + + appMock.commandLine.getSwitchValue.mockReturnValue('') + expect(hasUsableLinuxDisplay({ ...env, ELECTRON_OZONE_PLATFORM_HINT: 'x11' })).toBe(false) + }) + + it('never gates a non-Linux platform', async () => { + setPlatform('darwin') + const { hasUsableLinuxDisplay } = await import('./ensure-virtual-display') + + expect(hasUsableLinuxDisplay({})).toBe(true) + expect(statSyncMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index a9d517f9f99..6b78c05aa52 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -1,5 +1,6 @@ -import { spawn, spawnSync, type ChildProcess } from 'node:child_process' -import { existsSync, readFileSync, rmSync } from 'node:fs' +import { spawn, type ChildProcess } from 'node:child_process' +import { readFileSync, rmSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' import { app } from 'electron' // Why: headless `orca serve` backs browser panes with offscreen BrowserWindows. @@ -12,6 +13,8 @@ const XVFB_STARTUP_TIMEOUT_MS = 5_000 const XVFB_POLL_INTERVAL_MS = 50 const VIRTUAL_DISPLAY_NUMBER = 99 const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}` +const XVFB_INSTALL_GUIDANCE = + 'Install `xvfb` on Debian/Ubuntu or `xorg-x11-server-Xvfb` on RPM-based systems.' let xvfbProcess: ChildProcess | null = null @@ -33,32 +36,55 @@ function xDisplayLockPath(displayNumber: number): string { return `/tmp/.X${displayNumber}-lock` } -// Why: a socket file can outlive the X server that made it. The X lock file holds -// the server PID; if that process is gone, the display is dead despite the socket. -function isDisplayServerAlive(displayNumber: number): boolean { - const lockPath = xDisplayLockPath(displayNumber) - if (!existsSync(lockPath)) { - // No lock means no server claimed this display; the bare socket is stale. - return false - } +// Why: a socket file can outlive the X server that made it. The X lock file holds the server PID; +// if that process is gone, the display is dead despite the socket. `missing` is a third outcome the +// two callers must treat differently — see each call site. +type DisplayLockProbe = 'alive' | 'dead' | 'missing' + +function probeDisplayLock(displayNumber: number): DisplayLockProbe { let pid: number try { - pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) - } catch { - return false + pid = Number.parseInt(readFileSync(xDisplayLockPath(displayNumber), 'utf8').trim(), 10) + } catch (error) { + // An unreadable lock is a lock we cannot clear: treat it as dead, not absent. + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'missing' : 'dead' } if (!Number.isInteger(pid) || pid <= 0) { - return false + return 'dead' } try { // signal 0 probes existence without affecting the process. process.kill(pid, 0) - return true - } catch { - return false + return 'alive' + } catch (error) { + // EPERM means the PID exists under another uid — a root-owned X server is still live. + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPERM' + ? 'alive' + : 'dead' } } +/** + * Liveness for a display Orca did not create. An X server writes its lock beside the socket and + * both survive a crash (verified against Xvfb under SIGKILL), so a socket with no lock was never + * left by a crashed server — it is an endpoint published from elsewhere: a container bind-mounting + * only /tmp/.X11-unix, WSLg, or a foreign PID namespace. We cannot judge those, and refusing them + * blocks startup on displays that work. + */ +function isForeignDisplayServerAlive(displayNumber: number): boolean { + return probeDisplayLock(displayNumber) !== 'dead' +} + +/** + * Liveness for Orca's own VIRTUAL_DISPLAY_NUMBER. Stricter on purpose: `removeStaleDisplayArtifacts` + * unlinks the lock before the socket, so a lockless socket here is Orca's own half-finished + * teardown, not a foreign endpoint. Adopting it would resurrect the orphan-socket bug and stop the + * cleanup below from self-healing. + */ +function isManagedDisplayServerAlive(displayNumber: number): boolean { + return probeDisplayLock(displayNumber) === 'alive' +} + function removeStaleDisplayArtifacts(displayNumber: number): void { for (const path of [xDisplayLockPath(displayNumber), xvfbSocketPath(displayNumber)]) { try { @@ -69,30 +95,126 @@ function removeStaleDisplayArtifacts(displayNumber: number): void { } } -function hasXvfbBinary(): boolean { - // Why: spawnSync `which` is cheap and avoids spawning Xvfb only to fail; a - // clear up-front warning beats a cryptic ENOENT mid-startup. - const result = spawnSync('which', ['Xvfb'], { stdio: 'ignore' }) - return result.status === 0 -} - function sleepSync(ms: number): void { // Why: this runs in the synchronous pre-whenReady startup path, so block // without spinning the CPU or spawning a process. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } -function waitForDisplaySocket(displayNumber: number, deadline: number): boolean { - const socket = xvfbSocketPath(displayNumber) - // Why: Xvfb creates its socket asynchronously after spawn; Electron must not - // boot before it exists or display init still fails. +// Why not socket presence alone: a stale socket we failed to unlink (a root-owned one left by a +// crashed system Xvfb, which `User=orca` serve cannot remove) still exists after our own Xvfb +// refused to bind the display. Treating that as ready sets DISPLAY to a dead server and Chromium +// dies in Ozone init with SIGSEGV instead of reporting an unusable display. +function waitForDisplayReady(displayNumber: number, deadline: number): boolean { + const isReady = (): boolean => + isUnixSocket(xvfbSocketPath(displayNumber)) && isManagedDisplayServerAlive(displayNumber) while (Date.now() < deadline) { - if (existsSync(socket)) { + if (isReady()) { return true } sleepSync(XVFB_POLL_INTERVAL_MS) } - return existsSync(socket) + return isReady() +} + +// Validate display syntax and local sockets before Chromium reaches Ozone initialization. +export function hasUsableLinuxDisplay(env: NodeJS.ProcessEnv = process.env): boolean { + if (process.platform !== 'linux') { + return true + } + + const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform').trim().toLowerCase() + const ozonePlatformHint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase() + const selectedPlatform = + ozonePlatform === 'x11' || ozonePlatform === 'wayland' + ? ozonePlatform + : ozonePlatformHint === 'x11' || ozonePlatformHint === 'wayland' + ? ozonePlatformHint + : null + + if (selectedPlatform === 'x11') { + return hasUsableXDisplay(env.DISPLAY) + } + if (selectedPlatform === 'wayland') { + return hasUsableWaylandDisplay(env) + } + return hasUsableXDisplay(env.DISPLAY) || hasUsableWaylandDisplay(env) +} + +export const MISSING_LINUX_DISPLAY_MESSAGE = [ + 'Orca needs a usable display server, but the selected X11 or Wayland endpoint is unavailable.', + 'Check DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and any --ozone-platform override.', + `Use \`orca-ide serve\` to run headless. On a bare server, ${XVFB_INSTALL_GUIDANCE}` +].join('\n') + +// Why: an X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which leaves no +// filesystem socket to stat. Abstract addresses are kernel-owned and vanish the moment the owner +// exits, so an entry here is proof of a live server — no lock file needed, and no stale entry is +// possible. Refusing these was a hard startup failure with no workaround. +function hasAbstractXSocket(displayNumber: number): boolean { + let table: unknown + try { + table = readFileSync('/proc/net/unix', 'utf8') + } catch { + return false + } + if (typeof table !== 'string') { + return false + } + const address = `@${xvfbSocketPath(displayNumber)}` + return table + .split('\n') + .some((line) => line.slice(line.lastIndexOf(' ') + 1).trimEnd() === address) +} + +function isUnixSocket(path: string): boolean { + try { + return statSync(path).isSocket() + } catch { + return false + } +} + +function hasUsableXDisplay(value: string | undefined): boolean { + const display = value?.trim() + if (!display) { + return false + } + + const localDisplay = /^(?:unix\/?)?:(\d+)(?:\.\d+)?$/i.exec(display) + // Remote endpoints cannot be proven with local socket checks. + if (!localDisplay) { + return /^\S+:\d+(?:\.\d+)?$/.test(display) + } + const displayNumber = Number(localDisplay[1]) + if (isUnixSocket(xvfbSocketPath(displayNumber))) { + // Why the managed number is never treated as foreign: Orca's own teardown unlinks the lock + // before the socket, so a lockless socket on VIRTUAL_DISPLAY_NUMBER is our own half-finished + // cleanup even when DISPLAY names it explicitly. Trusting it there would accept a dead display. + return displayNumber === VIRTUAL_DISPLAY_NUMBER + ? isManagedDisplayServerAlive(displayNumber) + : isForeignDisplayServerAlive(displayNumber) + } + return hasAbstractXSocket(displayNumber) +} + +function hasUsableWaylandDisplay(env: NodeJS.ProcessEnv): boolean { + // Why: WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so there is no + // path to stat and WAYLAND_DISPLAY may be unset entirely. Its presence IS the display. + const inheritedFd = env.WAYLAND_SOCKET?.trim() + if (inheritedFd && /^\d+$/.test(inheritedFd)) { + return true + } + const display = env.WAYLAND_DISPLAY?.trim() + if (!display) { + return false + } + if (isAbsolute(display)) { + return isUnixSocket(display) + } + + const runtimeDir = env.XDG_RUNTIME_DIR?.trim() + return Boolean(runtimeDir && isAbsolute(runtimeDir) && isUnixSocket(join(runtimeDir, display))) } /** @@ -107,16 +229,17 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo configureHeadlessServeChromiumFlags() - // Why: respect an externally provided display (a real X server, or the image - // already running its own Xvfb). Don't start a competing one. - if (process.env.DISPLAY && process.env.DISPLAY.trim().length > 0) { - return true - } - - if (!hasXvfbBinary()) { + // Offscreen serve windows require X11; Wayland alone still needs Xvfb. + // Never delete artifacts from an externally managed display: a container may + // expose its socket without the host lock/PID being visible here. + const configuredDisplay = process.env.DISPLAY?.trim() + if (configuredDisplay) { + if (hasUsableXDisplay(configuredDisplay)) { + return true + } console.warn( - '[serve] Xvfb not found; browser panes are unavailable on this headless Linux host. ' + - 'Install Xvfb (e.g. `apt-get install xvfb`) or set DISPLAY to enable them.' + `[serve] DISPLAY=${configuredDisplay} is not verifiably live; leaving it untouched. ` + + 'Unset DISPLAY to let Orca start its own Xvfb.' ) return false } @@ -124,8 +247,8 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo // Why: reuse an existing display ONLY if a live X server actually backs it. // A crashed prior run can leave an orphan socket; trusting it by path alone // would advertise browser support that then fails at tab creation. - if (existsSync(xvfbSocketPath(VIRTUAL_DISPLAY_NUMBER))) { - if (isDisplayServerAlive(VIRTUAL_DISPLAY_NUMBER)) { + if (isUnixSocket(xvfbSocketPath(VIRTUAL_DISPLAY_NUMBER))) { + if (isManagedDisplayServerAlive(VIRTUAL_DISPLAY_NUMBER)) { process.env.DISPLAY = VIRTUAL_DISPLAY return true } @@ -147,6 +270,11 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo xvfbProcess.once('error', (error) => { console.warn('[serve] Xvfb failed to start:', error instanceof Error ? error.message : error) }) + // PATH lookup failures emit asynchronously, but a successful spawn has a PID immediately. + if (xvfbProcess.pid === undefined) { + xvfbProcess = null + return false + } } catch (error) { console.warn( '[serve] Could not start Xvfb:', @@ -155,9 +283,12 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo return false } - const ready = waitForDisplaySocket(VIRTUAL_DISPLAY_NUMBER, Date.now() + XVFB_STARTUP_TIMEOUT_MS) + const ready = waitForDisplayReady(VIRTUAL_DISPLAY_NUMBER, Date.now() + XVFB_STARTUP_TIMEOUT_MS) if (!ready) { - console.warn('[serve] Xvfb did not become ready in time; browser panes may be unavailable.') + console.warn( + `[serve] Xvfb did not take ownership of ${VIRTUAL_DISPLAY}; browser panes are unavailable. ` + + 'A stale socket from another user can block the rebind.' + ) stopVirtualDisplay() return false } diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 89be84d2119..918fd844364 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void { state.managedWslCliStartupBarrierReady ]) }) + // Why separate from the first-window barrier: host Git needs the shell-PATH + // generation and the managed WSL CLI registration, not a daemon PTY provider + // or a hook-server bind. Bundling them made worktree hydration wait on a + // terminal service it never calls. + ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => { + await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady]) + }) ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { await Promise.all([ state.firstWindowStartupServicesReady, diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index eb2a51cb7ff..acbe42360e8 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -2,8 +2,7 @@ import { app, ipcMain, powerMonitor, session } from 'electron' import { is } from '@electron-toolkit/utils' import os from 'node:os' import { join } from 'node:path' -import { maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' -import { maybeRedirectPackagedCliEntryLaunch } from './packaged-cli-entry-redirect' +import { maybeRedirectCliLaunch } from './cli-launch-redirect' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' import { configureDevUserDataPath, @@ -78,7 +77,11 @@ import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' -import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' +import { + ensureVirtualDisplayForHeadlessServe, + hasUsableLinuxDisplay, + MISSING_LINUX_DISPLAY_MESSAGE +} from './ensure-virtual-display' import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' @@ -91,25 +94,15 @@ export type MainProcessPreflightOptions = { /** Performs all module-scope work that must happen before Electron's ready event. */ export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. - // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. - // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its - // command-name lookup finds a port number and strands the launch in an in-process serve. The - // packaged-CLI one matches on the entry path instead, so order cannot affect it either way. - const packagedRedirect = maybeRedirectPackagedCliEntryLaunch({ + // The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim. + // Direct serve stays in-process so its signal handlers own all children. + const cliLaunchRedirect = maybeRedirectCliLaunch({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, execPath: process.execPath }) - if (packagedRedirect.redirected) { - app.exit(packagedRedirect.status) - } - const appImageRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath - }) - if (appImageRedirect.redirected) { - app.exit(appImageRedirect.status) + if (cliLaunchRedirect.redirected) { + app.exit(cliLaunchRedirect.status) } // Why: extracted AppRun / binary launches can land CLI-form `serve` args on the // Electron process without the CLI rewrite that injects `--serve` (#12677). @@ -118,6 +111,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b process.argv = normalizeServeModeArgv(process.argv) } state.isServeMode = process.argv.includes('--serve') + // Fail before Chromium's missing-display teardown can segfault (#13719). + if (app.isPackaged && !state.isServeMode && !hasUsableLinuxDisplay()) { + process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`) + app.exit(1) + } if (state.isServeMode) { reserveServeStdoutForReadiness() } @@ -317,6 +315,11 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode: state.isServeMode }) + // Why: continuing without Xvfb lets Ozone initialize without a display and SIGSEGV (#17615). + if (state.isServeMode && !state.headlessBrowserDisplayAvailable) { + process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`) + app.exit(1) + } initializeSyntheticTitleRuntime() registerGpuLifecycleHandlers() return true diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 61203bc3164..a4149e13ba7 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -24,6 +24,7 @@ import { shutdownObservability } from '../observability' import { isQuittingForUpdate } from '../updater' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { stopTccPromptNotice } from '../macos-tcc-prompt-notice' +import { cancelHistoryGc } from '../terminal-history-gc' import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' import { mainProcessState as state } from './main-process-state' import { isDevParentShutdownRequested } from './configure-process' @@ -82,6 +83,9 @@ function installBeforeQuitHandler(): void { state.repoMaintenanceShutdown = awaitPackedRefsLockRelease() // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). state.rateLimits?.stop() + // Why safe on a vetoed quit: background history GC is idempotent and re-scheduled next launch, + // so abandoning the walk here only costs one deferred sweep, never a half-applied prune. + cancelHistoryGc() }) } diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 78061fb439c..9df28ecea8c 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -280,7 +280,7 @@ export async function initializeMainProcessRuntimeLaunch( } let serveOptions: ReturnType | null = null try { - serveOptions = state.isServeMode ? getServeOptions() : null + serveOptions = state.isServeMode ? getServeOptions(process.argv) : null } catch (error) { console.error(error instanceof Error ? error.message : String(error)) app.exit(1) @@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch( state.serveOptions = serveOptions const runtimeRpc = installRuntimeRpc(runtime, serveOptions) const shellPathReady = shellPathHydration.whenReady() + // Why published: the renderer's git-environment barrier must fence on the same + // generation the terminal startup services wait for, not a later re-read. + state.shellPathReady = shellPathReady let desktopWindow: BrowserWindow | null = null if (process.platform === 'win32' && app.isPackaged && !serveOptions) { const desktopStartup = startWindowsDesktopBeforeShellPathReady({ diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts index 367bdf6d033..2be4d47d071 100644 --- a/src/main/startup/main-process-serve.ts +++ b/src/main/startup/main-process-serve.ts @@ -4,45 +4,9 @@ import { app } from 'electron' import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' import { notifyServeSupervisorReady } from '../serve-update-handoff' import { mainProcessState as state } from './main-process-state' +import { getServeOptions, type ServeOptions } from './serve-options' -export type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -export function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} +export { getServeOptions, type ServeOptions } export function getBundledWebClientRoot(): string | undefined { const appPath = app.getAppPath() diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 2e583830a48..3b2ba9cbd71 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -35,6 +35,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt connectionId, payload, receivedAt, + evidenceObservedAt, stateStartedAt, launchToken, providerSession, @@ -57,6 +58,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt worktreeId, connectionId, receivedAt, + ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(observation ? { observation } : {}), @@ -88,6 +90,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt worktreeId, connectionId, receivedAt, + ...(evidenceObservedAt !== undefined ? { evidenceObservedAt } : {}), stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), diff --git a/src/main/startup/packaged-cli-entry-redirect.test.ts b/src/main/startup/packaged-cli-entry-redirect.test.ts deleted file mode 100644 index 4f91657eca0..00000000000 --- a/src/main/startup/packaged-cli-entry-redirect.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { win32 } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { - getPackagedCliEntryArgs, - maybeRedirectPackagedCliEntryLaunch -} from './packaged-cli-entry-redirect' - -const resourcesPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources' -const execPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\Orca.exe' -const cliEntryPath = win32.join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') - -describe('packaged CLI entry redirect', () => { - it('detects Windows GUI launches that received the unpacked CLI entrypoint', () => { - expect( - getPackagedCliEntryArgs( - [execPath, cliEntryPath.toUpperCase(), 'status', '--json'], - cliEntryPath, - 'win32' - ) - ).toEqual(['status', '--json']) - }) - - it('ignores normal desktop launches', () => { - expect(getPackagedCliEntryArgs([execPath, '--updated'], cliEntryPath, 'win32')).toBeNull() - }) - - it('ignores the entrypoint when it is only the executable itself (argv[0])', () => { - expect(getPackagedCliEntryArgs([cliEntryPath, 'status'], cliEntryPath, 'win32')).toBeNull() - }) - - it('does not match the entrypoint on non-Windows platforms', () => { - expect( - getPackagedCliEntryArgs([execPath, cliEntryPath, 'status'], cliEntryPath, 'linux') - ).toBeNull() - }) - - it('spawns the in-package CLI in Electron node mode before the single-instance lock can win', () => { - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: [execPath, cliEntryPath, 'status', '--json'], - env: { - NODE_OPTIONS: '--inspect', - NODE_REPL_EXTERNAL_MODULE: 'external-loader' - }, - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 0 }) - expect(spawn).toHaveBeenCalledWith(execPath, [cliEntryPath, 'status', '--json'], { - env: expect.objectContaining({ - ELECTRON_RUN_AS_NODE: '1', - ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1', - ORCA_NODE_OPTIONS: '--inspect', - ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader' - }), - stdio: 'inherit' - }) - const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined - expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') - expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') - }) - - it('never spawns an attacker-supplied script — only the computed in-package entry', () => { - const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) - const attackerScript = 'C:\\Users\\me\\evil.js' - - const result = maybeRedirectPackagedCliEntryLaunch({ - // An attacker placing some other script path in argv must not cause it to run. - argv: [execPath, attackerScript, 'status'], - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: false }) - expect(spawn).not.toHaveBeenCalled() - }) - - it('does not redirect development launches', () => { - const spawn = vi.fn() - - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: ['C:\\dev\\Orca.exe', cliEntryPath, 'status'], - platform: 'win32', - isPackaged: false, - resourcesPath, - execPath: 'C:\\dev\\Orca.exe', - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: false }) - expect(spawn).not.toHaveBeenCalled() - }) - - it('reports a clear failure instead of locating a missing entrypoint', () => { - const spawn = vi.fn() - const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - try { - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: [execPath, cliEntryPath, 'status'], - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => false, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 1 }) - expect(stderrWrite).toHaveBeenCalledWith( - `Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n` - ) - expect(spawn).not.toHaveBeenCalled() - } finally { - stderrWrite.mockRestore() - } - }) - - it('fails clearly instead of recursively redirecting when node mode already failed once', () => { - const spawn = vi.fn() - const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - try { - const result = maybeRedirectPackagedCliEntryLaunch({ - argv: [execPath, cliEntryPath, 'status', '--json'], - env: { - ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1' - }, - platform: 'win32', - isPackaged: true, - resourcesPath, - execPath, - exists: () => true, - spawn: spawn as never - }) - - expect(result).toEqual({ redirected: true, status: 1 }) - expect(stderrWrite).toHaveBeenCalledWith( - 'Unable to start the Orca CLI through Electron node mode.\n' - ) - expect(spawn).not.toHaveBeenCalled() - } finally { - stderrWrite.mockRestore() - } - }) -}) diff --git a/src/main/startup/packaged-cli-entry-redirect.ts b/src/main/startup/packaged-cli-entry-redirect.ts deleted file mode 100644 index 333da2fcdd8..00000000000 --- a/src/main/startup/packaged-cli-entry-redirect.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from 'node:child_process' -import { existsSync } from 'node:fs' -import { posix, win32 } from 'node:path' - -type RedirectResult = - | { - redirected: false - } - | { - redirected: true - status: number - } - -type RedirectOptions = { - argv?: string[] - env?: NodeJS.ProcessEnv - platform?: NodeJS.Platform - isPackaged?: boolean - resourcesPath?: string - execPath?: string - exists?: typeof existsSync - spawn?: typeof spawnSync -} - -// Why: set on the re-spawned node-mode child so a failure to honor -// ELECTRON_RUN_AS_NODE can't make us redirect forever in a tight loop. -const REDIRECT_ATTEMPT_ENV = 'ORCA_PACKAGED_CLI_ENTRY_REDIRECTED' - -/** - * Why: on Windows the bundled native launcher runs `Orca.exe ` - * with ELECTRON_RUN_AS_NODE=1. When that env var is dropped (e.g. a wrapper or - * shell that resets it), Orca boots as a GUI, loses the single-instance lock to - * an already-running window, and exits silently with no stdout. This detects the - * CLI-shaped launch — argv carrying the known in-package CLI entry path — and - * re-runs it in Electron node mode BEFORE the lock gate, then exits with the - * CLI's status. - * - * Security: the spawned program is always `execPath` (Orca.exe) and the script - * is always `cliEntryPath`, derived solely from `resourcesPath` + a fixed - * relative path — never taken from argv. argv only contributes the trailing - * CLI arguments forwarded to the already-trusted in-package CLI, and the - * redirect only fires when an argv element exactly equals that computed path, - * so it cannot be coerced into spawning an arbitrary script. - */ -export function maybeRedirectPackagedCliEntryLaunch(options: RedirectOptions = {}): RedirectResult { - const argv = options.argv ?? process.argv - const env = options.env ?? process.env - const platform = options.platform ?? process.platform - const isPackaged = options.isPackaged ?? false - const resourcesPath = options.resourcesPath ?? process.resourcesPath - const execPath = options.execPath ?? process.execPath - const exists = options.exists ?? existsSync - const spawn = options.spawn ?? spawnSync - const cliEntryPath = buildPackagedCliEntryPath(platform, resourcesPath) - const cliArgs = getPackagedCliEntryArgs(argv, cliEntryPath, platform) - - if (!isPackaged || !cliArgs) { - return { redirected: false } - } - if (env[REDIRECT_ATTEMPT_ENV] === '1') { - process.stderr.write('Unable to start the Orca CLI through Electron node mode.\n') - return { redirected: true, status: 1 } - } - if (!exists(cliEntryPath)) { - process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) - return { redirected: true, status: 1 } - } - - const result = spawn(execPath, [cliEntryPath, ...cliArgs], { - env: buildElectronRunAsNodeEnv(env), - stdio: 'inherit' - }) as SpawnSyncReturns - - if (result.error) { - process.stderr.write(`${result.error.message}\n`) - return { redirected: true, status: 1 } - } - - return { redirected: true, status: result.status ?? 1 } -} - -/** - * Returns the CLI arguments that follow the in-package CLI entrypoint in argv, - * or null when this is not a Windows CLI-shaped launch. Scoped to win32 because - * the AppImage redirect already covers the Linux equivalent. - */ -export function getPackagedCliEntryArgs( - argv: string[], - cliEntryPath: string, - platform: NodeJS.Platform -): string[] | null { - if (platform !== 'win32') { - return null - } - const expectedCliPath = normalizePathForPlatform(cliEntryPath, platform) - const cliEntryIndex = argv.findIndex( - (arg, index) => index > 0 && normalizePathForPlatform(arg, platform) === expectedCliPath - ) - return cliEntryIndex === -1 ? null : argv.slice(cliEntryIndex + 1) -} - -function buildPackagedCliEntryPath(platform: NodeJS.Platform, resourcesPath: string): string { - return getPathApi(platform).join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') -} - -function normalizePathForPlatform(value: string, platform: NodeJS.Platform): string { - const pathApi = getPathApi(platform) - const normalized = pathApi.normalize(pathApi.isAbsolute(value) ? value : pathApi.resolve(value)) - // Why: Windows paths are case-insensitive, so compare case-folded. - return platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function getPathApi(platform: NodeJS.Platform): typeof win32 | typeof posix { - return platform === 'win32' ? win32 : posix -} - -function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const childEnv = { ...env } - // Why: the CLI re-reads these from the ORCA_-prefixed copies; clearing the - // originals keeps Electron's own node bootstrap from inheriting them. - childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' - childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' - childEnv.ELECTRON_RUN_AS_NODE = '1' - childEnv[REDIRECT_ATTEMPT_ENV] = '1' - delete childEnv.NODE_OPTIONS - delete childEnv.NODE_REPL_EXTERNAL_MODULE - return childEnv -} diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index 3455c8c59ab..182bc94cc98 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -1,70 +1,67 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { getAppImageCliArgs } from './appimage-cli-redirect' +import { getCliLaunchArgs } from './cli-launch-redirect' import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' -// Why: index.ts runs CLI redirects before rewriting argv. Direct AppImage serve -// stays in Electron so launch switches do not cross into the strict Node-mode -// CLI parser; other CLI commands still depend on redirect ordering (#12677). - +const CLI_ENTRY_PATH = '/opt/orca/resources/app.asar.unpacked/out/cli/index.js' const REDIRECT_OPTIONS = { platform: 'linux' as const, isPackaged: true, commandNames: ['serve', 'status'] } -// A mounted AppImage is the case where the runtime does export these. -const MOUNTED_APPIMAGE_ENV = { APPIMAGE: '/opt/orca/Orca.AppImage', APPDIR: '/tmp/.mount_ab12' } function rewriteAsIndexDoes(argv: string[]): string[] { return argvRequestsServeMode(argv) ? normalizeServeModeArgv(argv) : argv } -describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { - const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json'] +describe('serve argv rewrite vs CLI launch redirect ordering', () => { + const launchArgv = [ + '/opt/orca/orca-ide', + '--disable-features=Vulkan', + 'serve', + '--port', + '7777', + '--json' + ] - it('keeps clean serve validation on the CLI path', () => { - expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([ - 'serve', - '--port', - '7777', - '--json' - ]) + it('leaves direct serve in the main process', () => { + expect(getCliLaunchArgs(launchArgv, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toBeNull() }) - it('keeps an injected Chromium switch in Electron before argv rewriting', () => { - const injected = [...launchArgv.slice(0, 2), '--disable-features=FedCm', ...launchArgv.slice(2)] - expect(getAppImageCliArgs(injected, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() - }) - - it('loses the redirect if the rewrite runs first', () => { + it('rewrites direct serve into the in-process flag shape', () => { const rewritten = rewriteAsIndexDoes(launchArgv) + expect(rewritten).toContain('--disable-features=Vulkan') expect(rewritten).toContain('--serve') - expect(getAppImageCliArgs(rewritten, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() + expect(rewritten).toContain('--serve-port') + expect(getCliLaunchArgs(rewritten, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toBeNull() }) it('leaves non-serve CLI commands redirectable either way', () => { const argv = ['/opt/orca/orca-ide', 'status'] expect(rewriteAsIndexDoes(argv)).toEqual(argv) - expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) + expect(getCliLaunchArgs(argv, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toEqual(['status']) + }) + + it('redirects serve help instead of binding a server', () => { + const argv = ['/opt/orca/orca-ide', 'serve', '--help'] + expect(rewriteAsIndexDoes(argv)).toEqual(argv) + expect(getCliLaunchArgs(argv, CLI_ENTRY_PATH, REDIRECT_OPTIONS)).toEqual(['serve', '--help']) }) // Why source text: the ordering is the preflight phase's executable statement order, and the // cases above stay green if it is reversed — nothing else would catch the regression. - it('keeps the preflight running both CLI redirects before the argv rewrite', () => { + it('keeps the preflight running the CLI redirect before the argv rewrite', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), 'utf8' ) - const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') - const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') + const cliRedirect = source.indexOf('maybeRedirectCliLaunch({') const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') const serveModeCheck = source.indexOf("state.isServeMode = process.argv.includes('--serve')") - expect(packagedRedirect).toBeGreaterThanOrEqual(0) - expect(appImageRedirect).toBeGreaterThanOrEqual(0) - expect(rewrite).toBeGreaterThan(packagedRedirect) - expect(rewrite).toBeGreaterThan(appImageRedirect) + expect(cliRedirect).toBeGreaterThanOrEqual(0) + expect(rewrite).toBeGreaterThan(cliRedirect) // The rewrite is pointless unless it lands before the flag it exists to inject is read. expect(serveModeCheck).toBeGreaterThan(rewrite) }) diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts index 13c340c8e10..53b0bb616a1 100644 --- a/src/main/startup/serve-mode-argv.test.ts +++ b/src/main/startup/serve-mode-argv.test.ts @@ -25,6 +25,19 @@ describe('serve-mode-argv', () => { expect(findServeSubcommandIndex(['app', '--user-data-dir', '/tmp/x', 'serve'])).toBe(3) }) + it('skips a space-separated Chromium switch value while locating serve', () => { + const argv = ['/AppRun', '--disable-features', 'Vulkan', 'serve', '--port', '6768'] + expect(findServeSubcommandIndex(argv)).toBe(3) + expect(normalizeServeModeArgv(argv)).toEqual([ + '/AppRun', + '--disable-features', + 'Vulkan', + '--serve', + '--serve-port', + '6768' + ]) + }) + it('refuses a help launch instead of binding a server', () => { // Why: `--help` is not a serve flag, so it used to be swallowed and the launch bound a // network-exposed runtime server with pairing on. The AppImage redirect routes help to the CLI. @@ -151,6 +164,14 @@ describe('serve-mode-argv', () => { ).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0']) }) + it('keeps equals-form values that start with a flag marker intact', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng'])).toEqual([ + '/AppRun', + '--serve', + '--serve-pairing-address=--no-pairng' + ]) + }) + it('translates serve flags in the mixed `--serve --port` form', () => { // Why: leaving these untranslated silently kept pairing enabled despite --no-pairing. expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([ diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts index 6b406faf80e..8441f116805 100644 --- a/src/main/startup/serve-mode-argv.ts +++ b/src/main/startup/serve-mode-argv.ts @@ -26,13 +26,14 @@ const CLI_TO_SERVE_VALUE_FLAG = new Map([ * Residual class: a flag outside this list whose space-separated value is literally `serve` would * read as the subcommand. Include switches that may arrive in either argv shape. */ -const VALUE_TAKING_FLAGS = new Set([ +export const VALUE_TAKING_FLAGS = new Set([ ...CLI_TO_SERVE_VALUE_FLAG.keys(), '--serve-port', '--serve-pairing-address', '--serve-project-root', '--disable-features', '--user-data-dir', + '--proxy-server', '--environment', '--pairing-code' ]) @@ -135,8 +136,7 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] { next.push(...argv.slice(i)) break } - // Why: the CLI accepts `--port=6768` as well as `--port 6768`, but - // getServeOptions only reads the next token, so `=` must be split apart. + // Why: keep the internal argv shape canonical even though getServeOptions accepts both forms. const eq = token.indexOf('=') const name = eq === -1 ? token : token.slice(0, eq) // Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true` @@ -154,7 +154,14 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] { continue } if (eq !== -1) { - next.push(valueFlag, token.slice(eq + 1)) + const value = token.slice(eq + 1) + // Preserve the unambiguous `=` form when its value starts with `--`; splitting + // it would make the value look like a second option to the direct parser. + if (value.startsWith('--')) { + next.push(`${valueFlag}=${value}`) + } else { + next.push(valueFlag, value) + } continue } next.push(valueFlag) diff --git a/src/main/startup/serve-options.test.ts b/src/main/startup/serve-options.test.ts new file mode 100644 index 00000000000..9e2bb06919d --- /dev/null +++ b/src/main/startup/serve-options.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { getServeOptions } from './serve-options' +import { normalizeServeModeArgv } from './serve-mode-argv' + +describe('getServeOptions', () => { + it('parses a valid launch', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-no-pairing']) + ).toEqual({ + json: false, + wsPort: 6768, + pairingAddress: null, + noPairing: true, + mobilePairing: false, + recipeJson: false, + projectRoot: null + }) + }) + + it('accepts equals-form values in the normalized shape', () => { + expect( + getServeOptions([ + '/AppRun', + '--serve', + '--serve-port=6768', + '--serve-pairing-address=127.0.0.1', + '--serve-project-root=/tmp/repo' + ]) + ).toMatchObject({ + wsPort: 6768, + pairingAddress: '127.0.0.1', + projectRoot: '/tmp/repo' + }) + }) + + it('uses the final occurrence of each value flag', () => { + expect( + getServeOptions([ + '/AppRun', + '--serve', + '--serve-port', + '6768', + '--serve-port=6769', + '--serve-pairing-address', + 'first.example', + '--serve-pairing-address=last.example', + '--serve-project-root', + '/first', + '--serve-project-root=/last' + ]) + ).toMatchObject({ + wsPort: 6769, + pairingAddress: 'last.example', + projectRoot: '/last' + }) + }) + + it('applies missing or invalid values only to the final occurrence', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-port', '--serve-port', '6768']).wsPort + ).toBe(6768) + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port']) + ).toThrow('Missing value for --serve-port.') + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port=bad']) + ).toThrow('Invalid --serve-port value: bad') + }) + + it('uses the final value of mixed boolean aliases', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-no-pairing', '--no-pairing=false']).noPairing + ).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--no-pairing=false', '--serve-no-pairing']).noPairing + ).toBe(true) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-mobile-pairing', '--mobile-pairing=0']) + .mobilePairing + ).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-recipe-json', '--recipe-json=false']) + .recipeJson + ).toBe(false) + }) + + it('keeps JSON enabled for an equals-form global flag', () => { + expect(getServeOptions(['/AppRun', '--serve', '--json=false']).json).toBe(true) + }) + + it('accepts an equals-form value that resembles a pairing flag', () => { + const argv = normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng']) + expect(getServeOptions(argv).pairingAddress).toBe('--no-pairng') + }) + + it('shares cross-flag validation with the CLI-form launch', () => { + const argv = normalizeServeModeArgv([ + '/opt/orca/orca-ide', + 'serve', + '--no-pairing', + '--mobile-pairing' + ]) + expect(() => getServeOptions(argv)).toThrow(/either --mobile-pairing or --no-pairing/i) + }) + + it('rejects recipe JSON without runtime pairing and a project root', () => { + expect(() => + getServeOptions([ + '/AppRun', + '--serve', + '--serve-recipe-json', + '--serve-no-pairing', + '--serve-project-root', + '/tmp/repo' + ]) + ).toThrow(/requires runtime pairing.*--no-pairing/i) + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-recipe-json'])).toThrow( + /requires --project-root/i + ) + }) + + it('rejects a security-shaped typo while allowing Chromium switches', () => { + const normalized = normalizeServeModeArgv(['/AppRun', 'serve', '--no-pairng']) + expect(() => getServeOptions(normalized)).toThrow(/Unknown flag --no-pairng.*--no-pairing/i) + expect( + getServeOptions(['/AppRun', '--serve', '--disable-gpu', '--disable-features=Vulkan']) + .noPairing + ).toBe(false) + }) + + it('still rejects a flag-shaped space value, as the CLI does', () => { + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-pairing-address', '--no-pairng']) + ).toThrow(/Unknown flag --no-pairng.*--no-pairing/i) + }) + + it('ignores serve-looking arguments after the terminator', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--', '--serve-port', '1', '--serve-no-pairing']) + ).toEqual({ + json: false, + pairingAddress: null, + noPairing: false, + mobilePairing: false, + recipeJson: false, + projectRoot: null + }) + }) + + it('requires a port value', () => { + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port'])).toThrow( + 'Missing value for --serve-port.' + ) + }) + + it.each(['', '--serve-json', '--'])('rejects an unusable port value %j', (value) => { + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port', value])).toThrow( + 'Missing value for --serve-port.' + ) + }) +}) diff --git a/src/main/startup/serve-options.ts b/src/main/startup/serve-options.ts new file mode 100644 index 00000000000..c0398123797 --- /dev/null +++ b/src/main/startup/serve-options.ts @@ -0,0 +1,134 @@ +import { + getServeFlagTypoError, + getServeOptionValidationError +} from '../../shared/serve-option-validation' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +function optionsBeforeTerminator(argv: readonly string[]): readonly string[] { + const terminatorIndex = argv.indexOf('--') + return terminatorIndex === -1 ? argv : argv.slice(0, terminatorIndex) +} + +function optionName(token: string): string { + const equalsIndex = token.indexOf('=') + return equalsIndex === -1 ? token : token.slice(0, equalsIndex) +} + +function lastValueOccurrence( + argv: readonly string[], + flags: readonly string[] +): string | null | undefined { + const flagNames = new Set(flags) + let value: string | null | undefined + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]! + const name = optionName(token) + if (!flagNames.has(name)) { + continue + } + + const equalsIndex = token.indexOf('=') + if (equalsIndex !== -1) { + const assigned = token.slice(equalsIndex + 1) + value = assigned || null + continue + } + + const next = argv[index + 1] + if (next !== undefined && !next.startsWith('--')) { + value = next || null + index += 1 + } else { + value = null + } + } + return value +} + +function valueAfter( + argv: readonly string[], + flags: readonly string[], + required: boolean, + displayFlag: string +): string | null { + const value = lastValueOccurrence(argv, flags) + if (value === undefined || value === null) { + if (required && value !== undefined) { + throw new Error(`Missing value for ${displayFlag}.`) + } + return null + } + return value +} + +function lastBooleanValue(argv: readonly string[], flags: readonly string[]): boolean { + const flagNames = new Set(flags) + let value = false + for (const token of argv) { + const name = optionName(token) + if (!flagNames.has(name)) { + continue + } + // CLI boolean flags are true only in bare form; `--flag=...` is a string value. + value = !token.includes('=') + } + return value +} + +function hasFlag(argv: readonly string[], flags: readonly string[]): boolean { + const flagNames = new Set(flags) + return argv.some((token) => flagNames.has(optionName(token))) +} + +export function getServeOptions(argv: readonly string[]): ServeOptions { + const optionsArgv = optionsBeforeTerminator(argv) + const typoError = getServeFlagTypoError(optionsArgv) + if (typoError) { + throw new Error(typoError) + } + + const rawPort = valueAfter(optionsArgv, ['--serve-port', '--port'], true, '--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + + const options: ServeOptions = { + // The CLI uses `flags.has('json')`, so even `--json=false` enables JSON output. + json: hasFlag(optionsArgv, ['--serve-json', '--json']), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter( + optionsArgv, + ['--serve-pairing-address', '--pairing-address'], + false, + '--serve-pairing-address' + ), + noPairing: lastBooleanValue(optionsArgv, ['--serve-no-pairing', '--no-pairing']), + mobilePairing: lastBooleanValue(optionsArgv, ['--serve-mobile-pairing', '--mobile-pairing']), + recipeJson: lastBooleanValue(optionsArgv, ['--serve-recipe-json', '--recipe-json']), + projectRoot: valueAfter( + optionsArgv, + ['--serve-project-root', '--project-root'], + false, + '--serve-project-root' + ) + } + const validationError = getServeOptionValidationError(options) + if (validationError) { + throw new Error(validationError) + } + return options +} diff --git a/src/main/startup/serve-signal-handlers.test.ts b/src/main/startup/serve-signal-handlers.test.ts index 36250cd4146..35c70ef87de 100644 --- a/src/main/startup/serve-signal-handlers.test.ts +++ b/src/main/startup/serve-signal-handlers.test.ts @@ -11,9 +11,11 @@ describe('registerServeSignalHandlers', () => { signalSource.emit('SIGINT') signalSource.emit('SIGINT') signalSource.emit('SIGTERM') + signalSource.emit('SIGHUP') - expect(quitApplication).toHaveBeenCalledTimes(3) + expect(quitApplication).toHaveBeenCalledTimes(4) expect(signalSource.listenerCount('SIGINT')).toBe(1) expect(signalSource.listenerCount('SIGTERM')).toBe(1) + expect(signalSource.listenerCount('SIGHUP')).toBe(1) }) }) diff --git a/src/main/startup/serve-signal-handlers.ts b/src/main/startup/serve-signal-handlers.ts index 3022d935ac5..0df48d5ea35 100644 --- a/src/main/startup/serve-signal-handlers.ts +++ b/src/main/startup/serve-signal-handlers.ts @@ -1,12 +1,13 @@ type ServeSignalSource = { - on(event: 'SIGINT' | 'SIGTERM', listener: () => void): unknown + on(event: 'SIGINT' | 'SIGTERM' | 'SIGHUP', listener: () => void): unknown } export function registerServeSignalHandlers( signalSource: ServeSignalSource, quitApplication: () => void ): void { - // Keep both listeners installed so duplicate delivery cannot fall through to default termination. + // Keep every listener installed so duplicate delivery cannot fall through to default termination. signalSource.on('SIGINT', quitApplication) signalSource.on('SIGTERM', quitApplication) + signalSource.on('SIGHUP', quitApplication) } diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts index 15623c2459f..8c60f276216 100644 --- a/src/main/startup/single-instance-lock-exit.electron.test.ts +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -6,11 +6,8 @@ import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-lock' -// Why #11935: the lock-loss gate runs before Electron `ready`, where `app.quit()` is deferred, so a -// duplicate headless `orca serve` kept executing the rest of startup, reached Linux Ozone/X11 init -// with no display, died with SIGSEGV, and systemd restarted it until the leaked AppImage FUSE mounts -// hit the kernel's 1000-mount ceiling. This runs the gate's own termination statement, lifted out of -// `src/main/index.ts`, under the real Electron binary. +// Why: `app.quit()` is deferred before Electron `ready`, so fatal startup gates must use the +// synchronous `app.exit()`. Run their shipped termination statements under the real binary. // // Why not a live lock race: Chromium's Linux ProcessSingleton only answers a second process once the // browser IO thread is up, which needs `ready` and therefore a display. On a display-less CI runner @@ -19,10 +16,10 @@ import { SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE } from './single-instance-loc // only a real process can settle is what the loser does next, which is what this file pins. const electronBinary = createRequire(import.meta.url)('electron') as string -const LOCK_LOST = 'LOCK_LOST' +const GATE_ENTERED = 'GATE_ENTERED' const CONTINUED_INTO_STARTUP = 'CONTINUED_INTO_STARTUP' const REACHED_TAIL = 'REACHED_TAIL' -const MARKER_ENV = 'ORCA_LOCK_FIXTURE_MARKER' +const MARKER_ENV = 'ORCA_PRE_READY_EXIT_FIXTURE_MARKER' const fixtureRoots: string[] = [] @@ -32,13 +29,13 @@ afterAll(() => { } }) -/** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ -function readLockLossTermination(): string { +/** Read the `app.*` termination statement from a pre-ready gate in the shipped entrypoint. */ +function readPreReadyTermination(gate: string): string { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), 'utf8' ) - const start = source.indexOf('if (!hasLock) {') + const start = source.indexOf(gate) expect(start).toBeGreaterThanOrEqual(0) const end = source.indexOf('\n }', start) expect(end).toBeGreaterThan(start) @@ -59,7 +56,7 @@ function buildFixtureMain(termination: string): string { `const marker = process.env.${MARKER_ENV}`, `const mark = (name) => appendFileSync(marker, name + '\\n')`, `const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = ${SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE}`, - `mark('${LOCK_LOST}')`, + `mark('${GATE_ENTERED}')`, termination, `mark('${CONTINUED_INTO_STARTUP}')`, // Why: stand in for the rest of `src/main/index.ts`, which on the reported host was display init. @@ -70,15 +67,15 @@ function buildFixtureMain(termination: string): string { type FixtureRun = { status: number | null; markers: string[] } -function runLockLossGate(termination: string): FixtureRun { - const root = mkdtempSync(join(tmpdir(), 'orca-lock-loss-')) +function runPreReadyGate(termination: string): FixtureRun { + const root = mkdtempSync(join(tmpdir(), 'orca-pre-ready-exit-')) fixtureRoots.push(root) const dir = join(root, 'fixture') const marker = join(root, 'markers.log') mkdirSync(dir, { recursive: true }) writeFileSync( join(dir, 'package.json'), - '{ "name": "orca-lock-loss-fixture", "main": "main.js" }' + '{ "name": "orca-pre-ready-exit-fixture", "main": "main.js" }' ) writeFileSync(join(dir, 'main.js'), buildFixtureMain(termination)) writeFileSync(marker, '') @@ -96,23 +93,34 @@ function runLockLossGate(termination: string): FixtureRun { } } -describe('#11935 pre-ready lock-loss termination under real Electron', () => { +describe('pre-ready termination under real Electron', () => { it('stops the duplicate launch before any further startup runs, with the already-running code', () => { - const termination = readLockLossTermination() + const termination = readPreReadyTermination('if (!hasLock) {') // Why: an empty slice would let the fixture fall through to its own exit and pass vacuously. expect(termination).not.toBe('') - const run = runLockLossGate(termination) + const run = runPreReadyGate(termination) - expect(run.markers).toEqual([LOCK_LOST]) + expect(run.markers).toEqual([GATE_ENTERED]) expect(run.status).toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) }, 90_000) + it('#17615 stops serve when display setup fails instead of entering Chromium startup', () => { + const termination = readPreReadyTermination( + 'if (state.isServeMode && !state.headlessBrowserDisplayAvailable) {' + ) + + const run = runPreReadyGate(termination) + + expect(run.markers).toEqual([GATE_ENTERED]) + expect(run.status).toBe(1) + }, 90_000) + it('reproduces the deferred graceful quit that let the doomed launch keep booting', () => { - const run = runLockLossGate('app.quit()') + const run = runPreReadyGate('app.quit()') // Why: pins the Electron semantic the fix rests on — pre-`ready` `quit()` schedules, it does not stop. - expect(run.markers).toEqual([LOCK_LOST, CONTINUED_INTO_STARTUP, REACHED_TAIL]) + expect(run.markers).toEqual([GATE_ENTERED, CONTINUED_INTO_STARTUP, REACHED_TAIL]) expect(run.status).not.toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) }, 90_000) }) diff --git a/src/main/terminal-history-gc.test.ts b/src/main/terminal-history-gc.test.ts new file mode 100644 index 00000000000..ece0d9af25f --- /dev/null +++ b/src/main/terminal-history-gc.test.ts @@ -0,0 +1,444 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' + +const { removeHostTreeMock } = vi.hoisted(() => ({ + removeHostTreeMock: vi.fn<(dir: string) => Promise>() +})) + +// Why intercept rather than no-op: the tombstone path each prune produces is the decision this +// suite reads, but the drain re-queues any tombstone still on disk after a "successful" removal, +// so the stub has to really delete or the queue never terminates. +vi.mock('./host-tree-removal', () => ({ + removeHostTree: removeHostTreeMock +})) + +import { readHistoryMeta } from './terminal-history' +import { + cancelPendingHistoryTreeRemovalRetries, + flushPendingWorktreeHistoryDeletions +} from './terminal-history-deletion' +import { cancelHistoryGc, runHistoryGc, scheduleHistoryGc } from './terminal-history-gc' + +const GC_MIN_AGE_MS = 5 * 60 * 1000 +const PENDING_DELETE_DIR_NAME = '.pending-delete' +const LIVE_WORKTREE_ID = 'repo-1::/path/live-wt' +const DEAD_WORKTREE_ID = 'repo-1::/path/dead-wt' + +let userDataDir: string +let historyRoot: string +let originalXdgDataHome: string | undefined + +/** + * The enumeration this exercises used to be a synchronous walk. Its replacement is an async + * fixed-worker pass, so the whole safety net is that both reach the same prune decision over a + * realistic tree: over-pruning here destroys scrollback the user still expects to have. + * + * A verbatim port of the pre-change decision logic, reporting names instead of deleting. + */ +function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set): string[] { + const decisions: string[] = [] + if (!existsSync(root)) { + return decisions + } + const now = Date.now() + for (const entry of readdirSync(root)) { + if (entry === PENDING_DELETE_DIR_NAME) { + continue + } + const entryPath = join(root, entry) + try { + const stats = statSync(entryPath) + if (!stats.isDirectory()) { + continue + } + try { + for (const file of readdirSync(entryPath)) { + statSync(join(entryPath, file)) + } + } catch { + // Skip size estimation on error. + } + if (!existsSync(join(entryPath, 'meta.json'))) { + continue + } + const meta = readHistoryMeta(entryPath) + if (!meta?.worktreeId) { + continue + } + if (!liveWorktreeIds.has(meta.worktreeId)) { + if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) { + continue + } + decisions.push(entry) + } + } catch { + // Skip individual entries that fail. + } + } + return decisions +} + +function seedDir(name: string, files: Record): string { + const dir = join(historyRoot, name) + mkdirSync(dir, { recursive: true }) + for (const [file, contents] of Object.entries(files)) { + writeFileSync(join(dir, file), contents) + } + return dir +} + +function meta(worktreeId: string | undefined, ageMs: number | null): string { + return JSON.stringify({ + ...(worktreeId === undefined ? {} : { worktreeId }), + ...(ageMs === null ? {} : { createdAt: new Date(Date.now() - ageMs).toISOString() }) + }) +} + +const OLD = GC_MIN_AGE_MS * 2 + +/** Every decision shape the walk has to get right, including the ones that must never prune. */ +function seedDecisionMatrix(): void { + seedDir('live-old', { 'meta.json': meta(LIVE_WORKTREE_ID, OLD), zsh_history: 'a' }) + seedDir('live-young', { 'meta.json': meta(LIVE_WORKTREE_ID, 0) }) + seedDir('orphan-old', { 'meta.json': meta(DEAD_WORKTREE_ID, OLD), zsh_history: 'b' }) + seedDir('orphan-no-createdat', { 'meta.json': meta(DEAD_WORKTREE_ID, null) }) + seedDir('orphan-unparseable-createdat', { + 'meta.json': JSON.stringify({ worktreeId: DEAD_WORKTREE_ID, createdAt: 'not-a-date' }) + }) + seedDir('orphan-young', { 'meta.json': meta(DEAD_WORKTREE_ID, 1_000) }) + seedDir('no-meta', { zsh_history: 'c' }) + seedDir('malformed-meta', { 'meta.json': '{ this is not json' }) + seedDir('truncated-meta', { 'meta.json': `{"worktreeId":"${DEAD_WORKTREE_ID}` }) + seedDir('empty-meta', { 'meta.json': '{}' }) + seedDir('array-meta', { 'meta.json': `["${DEAD_WORKTREE_ID}"]` }) + seedDir('null-meta', { 'meta.json': 'null' }) + seedDir('no-worktree-id', { 'meta.json': meta(undefined, OLD) }) + seedDir('oversize-meta', { + 'meta.json': JSON.stringify({ + worktreeId: DEAD_WORKTREE_ID, + createdAt: new Date(Date.now() - OLD).toISOString(), + pad: 'x'.repeat(64 * 1024) + }) + }) + // meta.json as a directory: stat succeeds, the read does not. + mkdirSync(join(historyRoot, 'meta-is-a-dir', 'meta.json'), { recursive: true }) + seedDir('empty-dir', {}) + // A plain file at the root is not a history directory. + writeFileSync(join(historyRoot, 'stray-file'), 'x') + mkdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME), { recursive: true }) +} + +/** Enough entries to run several worker batches and cross the cooperative-yield boundary. */ +function seedBulk(count: number, orphanEvery: number): void { + for (let i = 0; i < count; i++) { + const orphan = i % orphanEvery === 0 + seedDir(`bulk-${i}`, { + 'meta.json': meta(orphan ? `${DEAD_WORKTREE_ID}-${i}` : LIVE_WORKTREE_ID, OLD), + zsh_history: `entry-${i}`, + bash_history: `entry-${i}` + }) + } +} + +function survivingDirs(): Set { + return new Set( + readdirSync(historyRoot).filter( + (entry) => + entry !== PENDING_DELETE_DIR_NAME && statSync(join(historyRoot, entry)).isDirectory() + ) + ) +} + +beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-gc-')) + historyRoot = join(userDataDir, 'terminal-history') + mkdirSync(historyRoot, { recursive: true }) + installFakeAppEnvironment({ getPath: () => userDataDir }) + // Why: the fish sweep resolves a real user data dir otherwise, and would delete the + // developer's own orca fish history files while this suite runs. + originalXdgDataHome = process.env.XDG_DATA_HOME + process.env.XDG_DATA_HOME = userDataDir + removeHostTreeMock.mockReset() + removeHostTreeMock.mockImplementation(async (dir) => { + rmSync(dir, { recursive: true, force: true }) + }) +}) + +/** Tombstone paths the pass condemned, with the `..` rename suffix stripped. */ +function tombstonedNames(): Set { + return new Set( + removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.')) + ) +} + +afterEach(async () => { + cancelHistoryGc() + vi.useRealTimers() + await flushPendingWorktreeHistoryDeletions() + cancelPendingHistoryTreeRemovalRetries() + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome + } + rmSync(userDataDir, { recursive: true, force: true }) +}) + +describe('history GC prune decisions', () => { + it('prunes exactly the set the synchronous walk chose', async () => { + seedDecisionMatrix() + seedBulk(200, 7) + const live = new Set([LIVE_WORKTREE_ID]) + + const before = survivingDirs() + const expected = new Set(referenceSyncPruneDecisions(historyRoot, live)) + + await runHistoryGc(live) + + const after = survivingDirs() + const actual = new Set([...before].filter((entry) => !after.has(entry))) + + expect(expected.size).toBeGreaterThan(0) + expect([...actual].sort()).toEqual([...expected].sort()) + }) + + it('keeps every directory whose ownership cannot be established', async () => { + seedDecisionMatrix() + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + const after = survivingDirs() + for (const kept of [ + 'live-old', + 'live-young', + 'orphan-young', + 'no-meta', + 'malformed-meta', + 'truncated-meta', + 'empty-meta', + 'array-meta', + 'null-meta', + 'no-worktree-id', + 'oversize-meta', + 'meta-is-a-dir', + 'empty-dir' + ]) { + expect(after.has(kept)).toBe(true) + } + expect(after.has('orphan-old')).toBe(false) + expect(after.has('orphan-no-createdat')).toBe(false) + expect(after.has('orphan-unparseable-createdat')).toBe(false) + expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true) + }) + + it('refuses to prune anything when the live set is empty', async () => { + seedDecisionMatrix() + + await runHistoryGc(new Set()) + + expect(survivingDirs().has('orphan-old')).toBe(true) + expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([]) + }) + + it('does not throw when the history root does not exist', async () => { + rmSync(historyRoot, { recursive: true, force: true }) + await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined() + }) + + it('tombstones orphans instead of removing them on the calling thread', async () => { + seedDecisionMatrix() + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + // The recursive rm only ever sees a path already renamed into the tombstone queue. + for (const [dir] of removeHostTreeMock.mock.calls) { + expect(dir).toContain(PENDING_DELETE_DIR_NAME) + } + expect(tombstonedNames().has('orphan-old')).toBe(true) + }) + + it('drains pre-existing tombstones without scanning them as worktrees', async () => { + seedDecisionMatrix() + const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef') + mkdirSync(leftover, { recursive: true }) + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000')) + }) + + it('continues the pass after one orphan tombstone fails', async () => { + seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) }) + seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) }) + // A file where the tombstone root must be makes the first rename fail; mkdir cannot replace it. + writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory') + + await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined() + + // Nothing could be tombstoned, and both entries survive for a later pass to reclaim. + expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b'])) + }) +}) + +describe('history GC concurrency behaviour', () => { + it('joins a second call to the in-flight pass instead of walking twice', async () => { + seedDecisionMatrix() + const live = new Set([LIVE_WORKTREE_ID]) + + const first = runHistoryGc(live) + const second = runHistoryGc(live) + expect(second).toBe(first) + + await first + // Each rename produces its own tombstone, so a second overlapping walk would condemn twice. + const orphanRemovals = removeHostTreeMock.mock.calls.filter(([dir]) => + basename(dir).startsWith('orphan-old.') + ) + expect(orphanRemovals).toHaveLength(1) + }) + + it('starts a fresh pass once the previous one has settled', async () => { + seedDecisionMatrix() + const live = new Set([LIVE_WORKTREE_ID]) + await runHistoryGc(live) + const second = runHistoryGc(live) + await expect(second).resolves.toBeUndefined() + }) + + it('stops an in-flight walk on cancel without pruning', async () => { + seedDecisionMatrix() + seedBulk(300, 3) + const before = survivingDirs() + + const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID])) + // Cancelling before the root listing resolves means no entry is ever visited. + cancelHistoryGc() + await pass + + expect(survivingDirs()).toEqual(before) + }) + + it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => { + seedDecisionMatrix() + vi.useFakeTimers() + let resolveLiveIds: (ids: Set) => void = () => {} + scheduleHistoryGc( + () => + new Promise>((resolve) => { + resolveLiveIds = resolve + }) + ) + + await vi.advanceTimersByTimeAsync(10_000) + cancelHistoryGc() + resolveLiveIds(new Set([LIVE_WORKTREE_ID])) + await vi.advanceTimersByTimeAsync(0) + + expect(survivingDirs().has('orphan-old')).toBe(true) + }) + + it('coalesces duplicate scheduled startup GC calls', async () => { + vi.useFakeTimers() + const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set()) + + scheduleHistoryGc(getLiveWorktreeIds) + scheduleHistoryGc(getLiveWorktreeIds) + await vi.advanceTimersByTimeAsync(10_000) + + expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1) + }) +}) + +describe('history GC races an async walk introduces', () => { + it('survives a directory removed while the walk is in flight', async () => { + seedDecisionMatrix() + seedBulk(300, 5) + const live = new Set([LIVE_WORKTREE_ID]) + const vanishing = ['bulk-11', 'bulk-77', 'bulk-201'] + + const pass = runHistoryGc(live) + for (const name of vanishing) { + rmSync(join(historyRoot, name), { recursive: true, force: true }) + } + await expect(pass).resolves.toBeUndefined() + + // Every live directory the racer did not touch is still there. + expect(survivingDirs().has('live-old')).toBe(true) + expect(survivingDirs().has('bulk-1')).toBe(true) + for (const name of vanishing) { + expect(existsSync(join(historyRoot, name))).toBe(false) + } + }) + + it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => { + seedDir('being-written', {}) + seedBulk(200, 5) + const live = new Set([LIVE_WORKTREE_ID]) + + const pass = runHistoryGc(live) + writeFileSync( + join(historyRoot, 'being-written', 'meta.json'), + `{"worktreeId":"${DEAD_WORKTREE_ID}","created` + ) + await pass + + expect(survivingDirs().has('being-written')).toBe(true) + }) + + it('prunes a directory whose meta.json arrived after the directory did', async () => { + seedDir('late-meta', {}) + writeFileSync( + join(historyRoot, 'late-meta', 'meta.json'), + meta(`${DEAD_WORKTREE_ID}-late`, OLD) + ) + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + expect(survivingDirs().has('late-meta')).toBe(false) + }) +}) + +describe('history GC main-thread occupancy', () => { + it('yields to timers throughout the walk instead of blocking on it', async () => { + seedBulk(1_200, 40) + const live = new Set([LIVE_WORKTREE_ID]) + + const ticks = { sync: 0, async: 0 } + let maxAsyncGapMs = 0 + + // The pre-change walk is the control: a synchronous pass over the same tree cannot tick at all. + const syncTimer = setInterval(() => { + ticks.sync += 1 + }, 4) + referenceSyncPruneDecisions(historyRoot, live) + clearInterval(syncTimer) + + let last = performance.now() + const asyncTimer = setInterval(() => { + const now = performance.now() + maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4) + last = now + ticks.async += 1 + }, 4) + await runHistoryGc(live) + clearInterval(asyncTimer) + + // A synchronous pass cannot tick at all, however long it takes. + expect(ticks.sync).toBe(0) + expect(ticks.async).toBeGreaterThan(5) + // Generous because shared CI runners stall an idle timer by tens of ms on their own; the + // failure this guards against is a whole-walk block, which is seconds. + expect(maxAsyncGapMs).toBeLessThan(2_000) + }) +}) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts index d67e3a7dd5c..37467c89423 100644 --- a/src/main/terminal-history-gc.ts +++ b/src/main/terminal-history-gc.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { existsSync, readdirSync, statSync } from 'node:fs' +import { readdir, stat } from 'node:fs/promises' import { getHistoryRoot, listWslHistoryRoots, @@ -9,9 +9,11 @@ import { schedulePendingHistoryTreeRemovals, scheduleWorktreeHistoryTreeDeletion } from './terminal-history-deletion' -import { readHistoryMeta } from './terminal-history' +import { readHistoryMetaAsync } from './terminal-history' import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session' import { hashWorktreeId } from './terminal-history-id' +import { forEachWithConcurrency } from '../shared/map-with-concurrency' +import { yieldToEventLoop } from '../shared/event-loop-yield' // Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is // taken just before. A worktree created between the snapshot and GC execution @@ -20,100 +22,136 @@ import { hashWorktreeId } from './terminal-history-id' // to cover any realistic snapshot-to-scan delay. const GC_MIN_AGE_MS = 5 * 60 * 1000 -let scheduledHistoryGcTimer: ReturnType | null = null -let historyGcRunning = false +// Why a fixed worker pool over a frontier and not per-entry promise fan-out: a real +// history root holds thousands of directories, and starting every one at once queues +// tens of thousands of libuv requests before the first completes. 16 is deep enough to +// keep the default 4-thread pool saturated without monopolising the disk during startup. +const HISTORY_GC_SCAN_CONCURRENCY = 16 +// Why yield at all when every step already awaits I/O: a fully cached root resolves each +// await in a microtask, which never returns to the macrotask queue. This bounds that run. +const HISTORY_GC_YIELD_EVERY = 32 -/** Scan a single history root directory, pruning orphaned entries. - * Returns { totalDirs, orphaned, pruned, totalSizeKB }. */ -function gcScanRoot( - root: string, - liveWorktreeIds: Set -): { +let scheduledHistoryGcTimer: ReturnType | null = null +let historyGcStarting = false +let historyGcCancelled = false +let activeHistoryGc: Promise | null = null +let activeHistoryGcAbort: AbortController | null = null + +type GcRootScan = { totalDirs: number orphaned: number pruned: number totalSizeKB: number /** Every fish data dir a meta.json in this root names, for the orphan sweep. */ fishHistoryDirs: Set -} { - const result = { +} + +/** Inspect one history directory, tombstoning it when its worktree is gone. */ +async function gcScanEntry( + root: string, + entry: string, + liveWorktreeIds: Set, + now: number, + result: GcRootScan +): Promise { + const entryPath = join(root, entry) + try { + const stats = await stat(entryPath) + if (!stats.isDirectory()) { + return + } + result.totalDirs++ + + // Estimate directory size from meta.json + history files. + // Why a local accumulator: `result.totalSizeKB += ` + // reads the field before suspending and writes back a stale sum once workers interleave. + let dirSizeKB = 0 + try { + for (const file of await readdir(entryPath)) { + dirSizeKB += Math.ceil((await stat(join(entryPath, file))).size / 1024) + } + } catch { + // Skip size estimation on error, keeping whatever was measured first. + } + result.totalSizeKB += dirSizeKB + + // A missing, truncated, oversized or malformed meta.json reads back as null, and a + // null meta is never pruned — an entry whose ownership we cannot establish is kept. + const meta = await readHistoryMetaAsync(entryPath) + if (meta?.fishHistoryDir) { + result.fishHistoryDirs.add(meta.fishHistoryDir) + } + if (!meta?.worktreeId) { + return + } + + if (!liveWorktreeIds.has(meta.worktreeId)) { + // Why: avoid a TOCTOU race where a worktree is created after the + // live-ID snapshot but before GC runs. Directories younger than + // GC_MIN_AGE_MS are presumed still live and skipped. + if (meta.createdAt) { + const ageMs = now - new Date(meta.createdAt).getTime() + if (ageMs < GC_MIN_AGE_MS) { + return + } + } + + result.orphaned++ + // Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after + // launch — the same freeze the explicit-delete path already tombstones its way out of. + if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) { + result.pruned++ + console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) + } + } + } catch { + // Skip individual entries that fail. + } +} + +/** Scan a single history root directory, pruning orphaned entries. */ +async function gcScanRoot( + root: string, + liveWorktreeIds: Set, + signal: AbortSignal +): Promise { + const result: GcRootScan = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0, fishHistoryDirs: new Set() } - if (!existsSync(root)) { + + let entries: string[] + try { + entries = await readdir(root) + } catch { + // Absent or unreadable root: nothing to collect. return result } const now = Date.now() + // Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash. + const frontier = entries.filter((entry) => entry !== PENDING_DELETE_DIR_NAME) - for (const entry of readdirSync(root)) { - // Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash. - if (entry === PENDING_DELETE_DIR_NAME) { - continue + await forEachWithConcurrency( + frontier, + HISTORY_GC_SCAN_CONCURRENCY, + async (entry, index): Promise => { + if (signal.aborted) { + return + } + await gcScanEntry(root, entry, liveWorktreeIds, now, result) + if (index % HISTORY_GC_YIELD_EVERY === HISTORY_GC_YIELD_EVERY - 1) { + await yieldToEventLoop() + } } - const entryPath = join(root, entry) - try { - const stat = statSync(entryPath) - if (!stat.isDirectory()) { - continue - } - result.totalDirs++ - - // Estimate directory size from meta.json + history files. - try { - for (const file of readdirSync(entryPath)) { - result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024) - } - } catch { - // Skip size estimation on error. - } - - const metaPath = join(entryPath, 'meta.json') - if (!existsSync(metaPath)) { - // No meta.json — can't determine ownership, skip. - continue - } - - const meta = readHistoryMeta(entryPath) - if (meta?.fishHistoryDir) { - result.fishHistoryDirs.add(meta.fishHistoryDir) - } - if (!meta?.worktreeId) { - continue - } - - if (!liveWorktreeIds.has(meta.worktreeId)) { - // Why: avoid a TOCTOU race where a worktree is created after the - // live-ID snapshot but before GC runs. Directories younger than - // GC_MIN_AGE_MS are presumed still live and skipped. - if (meta.createdAt) { - const ageMs = now - new Date(meta.createdAt).getTime() - if (ageMs < GC_MIN_AGE_MS) { - continue - } - } - - result.orphaned++ - // Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after - // launch — the same freeze the explicit-delete path already tombstones its way out of. - if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) { - result.pruned++ - console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) - } - } - } catch { - // Skip individual entries that fail. - } - } + ) return result } -/** Run background GC to prune history directories for worktrees that are no - * longer in Orca's known live-worktree set. */ -export function runHistoryGc(liveWorktreeIds: Set): void { +async function executeHistoryGc(liveWorktreeIds: Set, signal: AbortSignal): Promise { try { // Why: finish tombstones left by quit mid-rm before scanning live worktree hashes. // Safe ahead of the guard below: these entries were already condemned by a @@ -130,14 +168,17 @@ export function runHistoryGc(liveWorktreeIds: Set): void { console.log('[pty:history:gc] Skipped: live worktree set is empty') return } - const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds) + const main = await gcScanRoot(getHistoryRoot(), liveWorktreeIds, signal) // Also scan WSL history directories (each distro has its own subdirectory). const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } const liveFishHistoryDirs = new Set(main.fishHistoryDirs) for (const distroRoot of listWslHistoryRoots()) { + if (signal.aborted) { + break + } schedulePendingHistoryTreeRemovals(distroRoot) - const r = gcScanRoot(distroRoot, liveWorktreeIds) + const r = await gcScanRoot(distroRoot, liveWorktreeIds, signal) wslTotals.totalDirs += r.totalDirs wslTotals.orphaned += r.orphaned wslTotals.pruned += r.pruned @@ -147,6 +188,11 @@ export function runHistoryGc(liveWorktreeIds: Set): void { } } + if (signal.aborted) { + console.log('[pty:history:gc] Cancelled mid-scan') + return + } + // Why a sweep on top of per-worktree deletion: a fish history file lives in // the user's fish data dir, so it outlives the directory that names it. A // crash between tombstone and removal, or a hand-deleted history dir, leaves @@ -178,28 +224,62 @@ export function runHistoryGc(liveWorktreeIds: Set): void { } } +/** Run background GC to prune history directories for worktrees that are no + * longer in Orca's known live-worktree set. Resolves when the pass finishes. */ +export function runHistoryGc(liveWorktreeIds: Set): Promise { + // Why join instead of starting a second pass: two walks would race each other's + // tombstone renames, and the loser's `scheduleWorktreeHistoryTreeDeletion` would + // report a failure for a directory the winner already condemned. + if (activeHistoryGc) { + return activeHistoryGc + } + const controller = new AbortController() + activeHistoryGcAbort = controller + activeHistoryGc = executeHistoryGc(liveWorktreeIds, controller.signal).finally(() => { + activeHistoryGc = null + activeHistoryGcAbort = null + }) + return activeHistoryGc +} + +/** Drop a pending GC and stop an in-flight walk at its next entry. */ +export function cancelHistoryGc(): void { + if (scheduledHistoryGcTimer !== null) { + clearTimeout(scheduledHistoryGcTimer) + scheduledHistoryGcTimer = null + } + // Why a flag as well: the timer has already fired while the live-worktree lookup is + // in flight, and there is no controller to abort until the scan itself starts. + historyGcCancelled = true + activeHistoryGcAbort?.abort() +} + /** Schedule GC after a delay so it runs after workspace hydration completes. * `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */ export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise>): void { // Why: main-window services can reattach during reload/reactivation; one // pending/running disk GC is enough and avoids duplicate startup I/O. - if (scheduledHistoryGcTimer !== null || historyGcRunning) { + if (scheduledHistoryGcTimer !== null || historyGcStarting || activeHistoryGc !== null) { return } + historyGcCancelled = false // Why 10s: avoids competing with startup-critical I/O while still running // early enough to clean up before the user notices disk usage (§7.6). scheduledHistoryGcTimer = setTimeout(async () => { scheduledHistoryGcTimer = null - historyGcRunning = true + historyGcStarting = true try { const liveIds = await getLiveWorktreeIds() - runHistoryGc(liveIds) + if (historyGcCancelled) { + return + } + await runHistoryGc(liveIds) } catch (err) { console.warn( `[pty:history:gc] Failed to enumerate live worktrees for GC: ${err instanceof Error ? err.message : String(err)}` ) } finally { - historyGcRunning = false + historyGcStarting = false } }, 10_000) } diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index 1cbb5f83438..8dd25ea7ddf 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -96,8 +96,6 @@ import { flushPendingWorktreeHistoryDeletions } from './terminal-history-deletion' -import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc' - const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt') describe('terminal-history', () => { @@ -688,179 +686,6 @@ describe('terminal-history', () => { }) }) - describe('runHistoryGc', () => { - it('coalesces duplicate scheduled startup GC calls', async () => { - vi.useFakeTimers() - existsSyncMock.mockReturnValue(false) - const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set()) - - scheduleHistoryGc(getLiveWorktreeIds) - scheduleHistoryGc(getLiveWorktreeIds) - - await vi.advanceTimersByTimeAsync(10_000) - - expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1) - }) - - it('prunes orphaned directories', () => { - existsSyncMock.mockImplementation((p: string) => { - // WSL root doesn't exist, so GC skips it - if (p.includes('terminal-history-wsl')) { - return false - } - return true - }) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('terminal-history')) { - return ['dir1', 'dir2'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockImplementation((p: string) => { - // Use a createdAt old enough to pass the GC age threshold - const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString() - if (p.includes('dir1')) { - return JSON.stringify({ worktreeId: 'live-wt', createdAt: oldDate }) - } - return JSON.stringify({ worktreeId: 'dead-wt', createdAt: oldDate }) - }) - - const liveIds = new Set(['live-wt']) - runHistoryGc(liveIds) - - // Should only prune dir2 (dead-wt), not dir1 (live-wt), and never recursive-rm on the main thread. - expect(rmSyncMock).not.toHaveBeenCalled() - expect(renameSyncMock).toHaveBeenCalledTimes(1) - expect(renameSyncMock).toHaveBeenCalledWith( - expect.stringContaining('dir2'), - expect.stringContaining(`.pending-delete${sep}dir2.`) - ) - expect(rmAsyncMock).toHaveBeenCalledWith( - expect.stringContaining(`.pending-delete${sep}dir2.`), - expect.objectContaining({ recursive: true, force: true }) - ) - }) - - // Why: an empty live set is what a store that fell back to default state - // looks like, and it is indistinguishable from a user with no worktrees — - // who has no history to collect either. Treating it as "everything is - // orphaned" turns a recoverable bad load into deleted shell history. - it('refuses to prune anything when the live set is empty', () => { - existsSyncMock.mockImplementation((p: string) => !p.includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('.pending-delete')) { - return [] - } - if (dir.endsWith('terminal-history')) { - return ['dir1', 'dir2'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ - worktreeId: 'some-wt', - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) - ) - - runHistoryGc(new Set()) - - expect(renameSyncMock).not.toHaveBeenCalled() - expect(rmSyncMock).not.toHaveBeenCalled() - expect(rmAsyncMock).not.toHaveBeenCalled() - }) - - it('continues GC after one orphan tombstone fails', async () => { - existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('.pending-delete')) { - return [] - } - if (dir.endsWith('terminal-history')) { - return ['broken', 'healthy'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ - worktreeId: 'orphan', - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) - ) - renameSyncMock.mockImplementationOnce(() => { - throw new Error('busy') - }) - - expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow() - expect(renameSyncMock).toHaveBeenCalledTimes(2) - expect(rmAsyncMock).toHaveBeenCalledTimes(1) - await flushPendingWorktreeHistoryDeletions() - }) - - it('skips recently-created directories to avoid TOCTOU race', () => { - existsSyncMock.mockImplementation((p: string) => { - if (p.includes('terminal-history-wsl')) { - return false - } - return true - }) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('terminal-history')) { - return ['fresh-dir'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - // createdAt is just now — younger than the 5-minute GC threshold - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() }) - ) - - runHistoryGc(new Set(['live-wt'])) - - // Should NOT prune because the directory is too young - expect(rmSyncMock).not.toHaveBeenCalled() - expect(renameSyncMock).not.toHaveBeenCalled() - }) - - it('does not throw when history root does not exist', () => { - existsSyncMock.mockReturnValue(false) - expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow() - expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history') - }) - - it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => { - let tombstonePresent = true - existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (String(dir).endsWith('.pending-delete')) { - return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : [] - } - if (String(dir).endsWith('terminal-history')) { - return ['.pending-delete'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - rmAsyncMock.mockImplementation(async () => { - tombstonePresent = false - }) - - runHistoryGc(new Set(['live-wt'])) - - // The tombstone queue is drained off-thread; GC must never rmSync it or count it as a worktree. - expect(rmSyncMock).not.toHaveBeenCalled() - expect(rmAsyncMock).toHaveBeenCalledWith( - expect.stringContaining('abc123.1700000000000.deadbeef'), - expect.objectContaining({ recursive: true, force: true }) - ) - await flushPendingWorktreeHistoryDeletions() - }) - }) - describe('WSL path conversion', () => { it('converts HISTFILE to Linux path for WSL cwd', () => { const originalPlatform = process.platform diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index 04c772b2fc7..07ad8f12c93 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -1,5 +1,6 @@ import { join, basename } from 'node:path' import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' import { dropInheritedOrcaFishHistory, fishHistorySessionName, @@ -131,7 +132,29 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null { if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) { return null } - const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8')) + return parseHistoryMeta(dir, readFileSync(metaPath, 'utf-8')) + } catch { + return null + } +} + +/** `readHistoryMeta` off the main thread, for scans that walk thousands of directories. */ +export async function readHistoryMetaAsync(dir: string): Promise { + try { + const metaPath = join(dir, 'meta.json') + // Why stat before read: the cap must reject an oversized meta.json without loading it. + if ((await stat(metaPath)).size > MAX_HISTORY_META_BYTES) { + return null + } + return parseHistoryMeta(dir, await readFile(metaPath, 'utf-8')) + } catch { + return null + } +} + +function parseHistoryMeta(dir: string, contents: string): HistoryDirMeta | null { + try { + const raw: unknown = JSON.parse(contents) if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return null } diff --git a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts index fcdbe4719cc..c124cb85779 100644 --- a/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts +++ b/src/main/text-generation/commit-message-text-generation-local-subprocess.test.ts @@ -164,7 +164,11 @@ describe('generateCommitMessageFromContext', () => { 'wsl.exe', ['-d', 'Ubuntu 24.04', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ - cwd: undefined, + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below), so the Windows-side cwd never decides where the agent runs. + cwd: expect.any(String), windowsHide: true, env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }) }) diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index d54f1d995f6..7ef438c06ae 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -235,7 +235,11 @@ describe('discoverCommitMessageModelsLocal', () => { 'wsl.exe', ['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)], expect.objectContaining({ - cwd: undefined, + // Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit + // Orca's own cwd, a deletable WSL UNC path when it was launched from a + // worktree. The Linux directory still rides inside the command (/mnt/c/repo, + // asserted below), so the Windows-side cwd never decides where discovery runs. + cwd: expect.any(String), windowsHide: true }) ) diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index 7a24483ca70..6a643ae64a5 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -1,14 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdateStatus } from '../shared/update-status-types' import type { registerAutoUpdaterHandlers } from './updater-events' -const { appMock, nativeUpdaterMock, getLinuxRootPackageTypeMock } = vi.hoisted(() => ({ +const { + appMock, + nativeUpdaterMock, + getLinuxPackageTypeMock, + getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstallMock +} = vi.hoisted(() => ({ appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: vi.fn() }, nativeUpdaterMock: { on: vi.fn() }, - getLinuxRootPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | null>(() => 'deb') + getLinuxPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | 'non-root' | 'unusable'>(() => 'deb'), + getLinuxRootPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | null>(() => 'deb'), + isExternallyManagedLinuxInstallMock: vi.fn<() => boolean>(() => false) })) vi.mock('electron', () => ({ @@ -19,7 +28,9 @@ vi.mock('electron', () => ({ // Why: only the packaged-marker resolver is faked so the real artifact tracking runs. vi.mock('./linux-update-package-type', () => ({ - getLinuxRootPackageType: getLinuxRootPackageTypeMock + getLinuxPackageType: getLinuxPackageTypeMock, + getLinuxRootPackageType: getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstall: isExternallyManagedLinuxInstallMock })) vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) })) @@ -59,7 +70,9 @@ function createContext(overrides?: Partial): HandlerContext { consumeMissingManifestPrereleaseFallbackResult: vi.fn(() => null), getPublishingWindowLastGoodCheck: vi.fn(() => null), getMissingManifestPrereleaseFallbackUserInitiated: vi.fn(() => null), - getCurrentStatus: vi.fn(() => ({ state: 'checking' }) as never), + getCurrentStatus: vi.fn( + () => ({ state: 'downloading', percent: 42, version: '1.0.61' }) as never + ), getActiveUpdateCheckEventAttemptId: vi.fn(() => 1), getKnownReleaseUrl: vi.fn(() => undefined), getPendingInstallVersion: vi.fn(() => '1.0.61'), @@ -107,7 +120,10 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { appMock.on.mockReset() nativeUpdaterMock.on.mockReset() appMock.getVersion.mockReset().mockReturnValue('1.0.51') + getLinuxPackageTypeMock.mockReset().mockReturnValue('deb') getLinuxRootPackageTypeMock.mockReset().mockReturnValue('deb') + isExternallyManagedLinuxInstallMock.mockReset().mockReturnValue(false) + appMock.isPackaged = true }) const register = async ( @@ -142,6 +158,94 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { }) }) + it.each(['deb', 'rpm'] as const)( + 'publishes manual-install recovery after a %s download', + async (packageType) => { + getLinuxPackageTypeMock.mockReturnValue(packageType) + getLinuxRootPackageTypeMock.mockReturnValue(packageType) + const { emit, context } = await register() + const fileName = packageType === 'deb' ? 'orca.deb' : 'orca.rpm' + + emit( + 'update-downloaded', + downloadedEvent({ + downloadedFile: `/home/tester/.cache/orca-updater/pending/${fileName}`, + files: [{ url: fileName, sha512: DEB_SHA512 }] + }) + ) + + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType, + reason: 'manual-install-required', + version: '1.0.61' + } + }) + } + ) + + it.each([ + ['missing', [{ url: 'orca-ide_1.0.61_amd64.deb' }]], + ['malformed', [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: 'not-a-digest' }]] + ])('does not offer recovery when the package digest is %s', async (_kind, files) => { + const { emit, context, getArtifact } = await register() + + emit('update-downloaded', downloadedEvent({ files })) + + const status = { + state: 'error', + message: + 'The downloaded package metadata could not be verified. Quit Orca before downloading and installing the update from the official release page.', + version: '1.0.61', + retryable: false + } + expect(context.sendStatus).toHaveBeenLastCalledWith(status) + expect(context.sendStatus).not.toHaveBeenCalledWith( + expect.objectContaining({ recovery: expect.anything() }) + ) + expect(getArtifact()).toBeNull() + }) + + it('publishes the normal downloaded state for AppImage builds', async () => { + getLinuxPackageTypeMock.mockReturnValue('non-root') + getLinuxRootPackageTypeMock.mockReturnValue(null) + const { emit, context } = await register() + + emit('update-downloaded', downloadedEvent()) + if (process.platform === 'darwin') { + const handler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + handler?.() + } + + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'downloaded', + version: '1.0.61', + releaseUrl: undefined + }) + }) + + it('blocks downloaded-state handling when the packaged marker is unusable', async () => { + getLinuxPackageTypeMock.mockReturnValue('unusable') + getLinuxRootPackageTypeMock.mockReturnValue(null) + const { emit, context, getArtifact } = await register() + + emit('update-downloaded', downloadedEvent()) + + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: + 'Orca could not verify the installed Linux package format, so it will not install this update automatically. Download the update from the official release page and install it manually.', + version: '1.0.61', + retryable: false + }) + expect(getArtifact()).toBeNull() + }) + it('passes the actual updater error into the install-failure handler', async () => { const handleQuitAndInstallFailure = vi.fn<(error?: unknown) => boolean>(() => true) const { emit, context } = await register({ handleQuitAndInstallFailure }) @@ -155,13 +259,64 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { expect(context.sendErrorStatus).not.toHaveBeenCalled() }) - it('drops the artifact once the update resolves as not available', async () => { - const { emit, getArtifact } = await register() + it('keeps manual-install recovery when a later check finds no newer release', async () => { + const { emit, context, getArtifact } = await register() emit('update-downloaded', downloadedEvent()) + // Why: the download already produced this exact status, so the assertion below could pass + // on that call alone. Clear it so only the second emit can satisfy it. + vi.mocked(context.sendStatus).mockClear() + + emit('update-not-available') + + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' })) + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + }) + + it('keeps manual-install recovery when a later check finds only the installed release', async () => { + const { emit, context, getArtifact } = await register() + emit('update-downloaded', downloadedEvent()) + + // Why: the download already produced this exact status, so the assertion below could pass + // on that call alone. Clear it so only the second emit can satisfy it. + vi.mocked(context.sendStatus).mockClear() + + emit('update-available', { version: '1.0.51' }) + + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' })) + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + }) + + it('clears recovery when a newer update takes over before no-update settles', async () => { + const { emit, context, getArtifact } = await register() + emit('update-downloaded', downloadedEvent()) + + emit('update-available', { version: '1.0.62' }) emit('update-not-available') expect(getArtifact()).toBeNull() + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'not-available', + userInitiated: undefined + }) }) it('drops the artifact when another version takes over the cycle', async () => { @@ -173,6 +328,74 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { expect(getArtifact()).toBeNull() }) + it('ignores a downloaded event for an older target', async () => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn(() => ({ state: 'available', version: '1.0.62' }) as never), + getPendingInstallVersion: vi.fn(() => '1.0.62') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toBeNull() + expect(context.sendStatus).not.toHaveBeenCalled() + }) + + it.each([ + ['idle', { state: 'idle' }], + ['not-available', { state: 'not-available' }], + ['check error', { state: 'error', message: 'check failed' }] + ] as const)( + 'ignores a downloaded event after the target is no longer active (%s)', + async (_name, status) => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn(() => status as never), + getPendingInstallVersion: vi.fn(() => '') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toBeNull() + expect(context.sendStatus).not.toHaveBeenCalled() + } + ) + + it('accepts a matching event when the pending cache target was cleared', async () => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn( + () => ({ state: 'downloading', percent: 42, version: '1.0.61' }) as never + ), + getPendingInstallVersion: vi.fn(() => '') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61' })) + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + }) + + it('ignores a downloaded event when the active status and pending target disagree', async () => { + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn( + () => ({ state: 'downloading', percent: 42, version: '1.0.62' }) as never + ), + getPendingInstallVersion: vi.fn(() => '1.0.62') + }) + + emit('update-downloaded', downloadedEvent()) + + expect(getArtifact()).toBeNull() + expect(context.sendStatus).not.toHaveBeenCalled() + }) + it('drops the artifact when progress reports a different pending version', async () => { const { emit, getArtifact } = await register({ getPendingInstallVersion: vi.fn(() => '1.0.62') @@ -184,13 +407,38 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { expect(getArtifact()).toBeNull() }) - it('keeps the artifact through a same-version recheck', async () => { - const { emit, getArtifact } = await register() - emit('update-downloaded', downloadedEvent()) + // #17702: the externallyManaged flag is spread onto the fallback object only, so a retained + // manual-install status must still win. Cross-version case: the host could self-update when it + // downloaded, and cannot now. + it.each([false, true])( + 'keeps manual-install recovery through a same-version recheck (externallyManaged=%s)', + async (externallyManaged) => { + isExternallyManagedLinuxInstallMock.mockReturnValue(externallyManaged) + let status: UpdateStatus = { state: 'downloading', percent: 100, version: '1.0.61' } + const { emit, context, getArtifact } = await register({ + getCurrentStatus: vi.fn(() => status) + }) + emit('update-downloaded', downloadedEvent()) - emit('update-available', { version: '1.0.61' }) - emit('download-progress', { percent: 100 }) + // Why: the download already emitted the manual-install status, so waitFor would pass on that + // call alone. Clear it so the assertion can only be satisfied by the recheck. + vi.mocked(context.sendStatus).mockClear() + status = { state: 'checking' } + emit('update-available', { version: '1.0.61' }) - expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61', path: DEB_PATH })) - }) + expect(getArtifact()).toEqual(expect.objectContaining({ version: '1.0.61', path: DEB_PATH })) + await vi.waitFor(() => + expect(context.sendStatus).toHaveBeenLastCalledWith({ + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } + }) + ) + } + ) }) diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index b77cd8a8cc5..d2b7f2a1e83 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -1,11 +1,8 @@ -import { app, autoUpdater as nativeUpdater } from 'electron' +import { app } from 'electron' import type { UpdateStatus } from '../shared/update-status-types' import { - consumeMacInstallGuardBypass, - deferMacQuitUntilInstallerReady, - handleMacInstallerReady, isMacInstallerReady, - isMacQuitAndInstallInFlight, + registerMacUpdaterEvents, resetMacInstallState } from './updater-mac-install' import { compareVersions } from './updater-fallback' @@ -13,10 +10,12 @@ import { fetchChangelog } from './updater-changelog' import type { ElectronAutoUpdater } from './electron-updater-loader' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { - captureLinuxPackageArtifact, - clearTrackedLinuxPackageArtifact, - clearTrackedLinuxPackageArtifactForOtherVersion -} from './linux-package-update-recovery' + getRetainedLinuxPackageManualInstallStatus, + resolveLinuxPackageDownloadedStatus, + shouldIgnoreDownloadedUpdateEvent +} from './linux-package-downloaded-status' +import { isExternallyManagedLinuxInstall } from './linux-update-package-type' +import * as linuxPackageRecovery from './linux-package-update-recovery' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -101,47 +100,14 @@ export function registerAutoUpdaterHandlers({ setAvailableVersion, setUserInitiatedCheck }: UpdaterHandlerContext): void { - // Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready". - if (process.platform === 'darwin') { - nativeUpdater.on('update-downloaded', () => { - const hasInstallableVersion = hasInstallableDownloadedVersion() - handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => { - // Send the held status only while its staged build is still installable. - sendStatus({ - state: 'downloaded', - version: getPendingInstallVersion(), - releaseUrl: getKnownReleaseUrl() - }) - }) - }) - } - - app.on('before-quit', (event) => { - if (!shouldDeferMacQuitForInstall()) { - return - } - if (consumeMacInstallGuardBypass()) { - recordUpdaterLifecycle('macos_before_quit_guard_bypassed') - return - } - if (isMacQuitAndInstallInFlight()) { - return - } - - // Why: quitting before Squirrel.Mac finishes staging leaves nothing to install; hold the quit until it's ready. - if ( - deferMacQuitUntilInstallerReady( - getCurrentStatus(), - hasInstallableDownloadedVersion(), - getPendingInstallVersion, - sendStatus - ) - ) { - recordUpdaterLifecycle('macos_before_quit_deferred', { - version: getPendingInstallVersion() - }) - event.preventDefault() - } + registerMacUpdaterEvents({ + getCurrentStatus, + hasInstallableDownloadedVersion, + getPendingInstallVersion, + getKnownReleaseUrl, + performQuitAndInstall, + shouldDeferMacQuitForInstall, + sendStatus }) autoUpdater.on('checking-for-update', () => { @@ -185,13 +151,18 @@ export function registerAutoUpdaterHandlers({ scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) } } - sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) + sendStatus( + getRetainedLinuxPackageManualInstallStatus() ?? { + state: 'not-available', + userInitiated: wasUserInitiated || undefined + } + ) return } // Why: only a genuinely newer offer supersedes the retained package; a publishing-window blip that // momentarily resolves an older tag must not destroy a still-valid recovery path. - clearTrackedLinuxPackageArtifactForOtherVersion(info.version) + linuxPackageRecovery.clearTrackedLinuxPackageArtifactForOtherVersion(info.version) // Why: fetch the changelog in main to avoid renderer-side CORS on onorca.dev. markUpdateAvailableEventPending(attemptId) @@ -228,7 +199,15 @@ export function registerAutoUpdaterHandlers({ } } - sendStatus({ state: 'available', version: info.version, changelog }) + sendStatus( + getRetainedLinuxPackageManualInstallStatus() ?? { + state: 'available', + version: info.version, + changelog, + // Why: the offer is real, but this host can never apply it — say so before a download is offered. + ...(isExternallyManagedLinuxInstall() ? { externallyManaged: true } : {}) + } + ) } finally { clearUpdateAvailableEventPending(attemptId) } @@ -241,7 +220,7 @@ export function registerAutoUpdaterHandlers({ } clearBackgroundCheckLaunchPending() resetMacInstallState() - clearTrackedLinuxPackageArtifact() + const retainedStatus = getRetainedLinuxPackageManualInstallStatus() const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult() const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() @@ -262,7 +241,11 @@ export function registerAutoUpdaterHandlers({ } } } - sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) + // Why: a later check can report no newer release while a verified deb/rpm is still waiting for + // the user to install it outside Orca. Keep both the artifact and its recovery card reachable. + sendStatus( + retainedStatus ?? { state: 'not-available', userInitiated: wasUserInitiated || undefined } + ) if (localBuildCheck || pinnedBuildCheck) { restoreReleaseUpdateSource() } @@ -271,7 +254,7 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('download-progress', (progress) => { clearBackgroundCheckLaunchPending() const version = getPendingInstallVersion() - clearTrackedLinuxPackageArtifactForOtherVersion(version) + linuxPackageRecovery.clearTrackedLinuxPackageArtifactForOtherVersion(version) sendStatus({ state: 'downloading', percent: Math.round(progress.percent), @@ -280,6 +263,16 @@ export function registerAutoUpdaterHandlers({ }) autoUpdater.on('update-downloaded', (info) => { + // Why: an earlier download can finish after a newer target replaced it; uncached pre-staged events have no target to compare. + if ( + shouldIgnoreDownloadedUpdateEvent( + getCurrentStatus(), + info.version, + getPendingInstallVersion() + ) + ) { + return + } clearBackgroundCheckLaunchPending() // Release downloads remain newer-only; the local source was validated before checking, and a pinned jump is explicit. if ( @@ -288,14 +281,17 @@ export function registerAutoUpdaterHandlers({ compareVersions(info.version, app.getVersion()) <= 0 ) { clearAvailableUpdateContext() - clearTrackedLinuxPackageArtifact() + linuxPackageRecovery.clearTrackedLinuxPackageArtifact() sendStatus({ state: 'not-available' }) return } - // Why: retain the verified artifact now — the 'error' event after a failed install no longer carries it. - captureLinuxPackageArtifact(info) const macInstallerReady = process.platform === 'darwin' ? isMacInstallerReady() : true recordUpdaterLifecycle('update_downloaded', { version: info.version, macInstallerReady }) + const linuxPackageStatus = resolveLinuxPackageDownloadedStatus(info) + if (linuxPackageStatus) { + sendStatus(linuxPackageStatus) + return + } // On macOS, defer 'downloaded' until Squirrel.Mac finishes processing; other platforms are ready immediately. if (process.platform === 'darwin' && !macInstallerReady) { // Keep the UI at 100% downloaded while Squirrel processes, to avoid a premature "ready to install". diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 22cfb049555..62ec3ff3b8c 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -49,13 +49,12 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean return ( right.state === 'error' && left.message === right.message && + left.version === right.version && + left.retryable === right.retryable && left.userInitiated === right.userInitiated && left.activeNudgeId === right.activeNudgeId && - // Why: clearing recovery must reach the renderer even when the message is unchanged, or dead actions stay enabled. - left.recovery?.kind === right.recovery?.kind && - left.recovery?.packageType === right.recovery?.packageType && - left.recovery?.reason === right.recovery?.reason && - left.recovery?.version === right.recovery?.version + // Recovery identity fences async actions, so same-valued recaptures must reach the renderer. + left.recovery === right.recovery ) } } diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index ff0b974bf7d..9a546dd8fac 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -10,8 +10,8 @@ const { getTrackedLinuxPackageArtifactMock, recordUpdaterLifecycleMock, resolveLinuxPackageInstallInstructionsMock, - revalidateLinuxPackageForInstallMock, - revealLinuxPackageMock, + resolveLinuxPackageRevealTargetMock, + showItemInFolderMock, resetHandlers } = vi.hoisted(() => { const updaterHandlers = new Map void)[]>() @@ -44,8 +44,8 @@ const { getTrackedLinuxPackageArtifactMock: vi.fn(), recordUpdaterLifecycleMock: vi.fn(), resolveLinuxPackageInstallInstructionsMock: vi.fn(), - revalidateLinuxPackageForInstallMock: vi.fn(), - revealLinuxPackageMock: vi.fn(), + resolveLinuxPackageRevealTargetMock: vi.fn(), + showItemInFolderMock: vi.fn(), resetHandlers: () => updaterHandlers.clear() } }) @@ -55,6 +55,7 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, autoUpdater: { on: vi.fn() }, powerMonitor: { on: vi.fn() }, + shell: { showItemInFolder: showItemInFolderMock }, net: { fetch: vi.fn() } })) @@ -78,15 +79,18 @@ vi.mock('./update-install-exit-watchdog', () => ({ vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock })) -vi.mock('./linux-update-package-type', () => ({ getLinuxRootPackageType: () => 'deb' })) +vi.mock('./linux-update-package-type', () => ({ + getLinuxPackageType: () => 'deb', + getLinuxRootPackageType: () => 'deb', + isExternallyManagedLinuxInstall: () => false +})) vi.mock('./linux-package-update-recovery', () => ({ - captureLinuxPackageArtifact: vi.fn(), + captureLinuxPackageArtifact: vi.fn(() => getTrackedLinuxPackageArtifactMock()), clearTrackedLinuxPackageArtifact: clearTrackedLinuxPackageArtifactMock, clearTrackedLinuxPackageArtifactForOtherVersion: vi.fn(), getTrackedLinuxPackageArtifact: getTrackedLinuxPackageArtifactMock, resolveLinuxPackageInstallInstructions: resolveLinuxPackageInstallInstructionsMock, - revalidateLinuxPackageForInstall: revalidateLinuxPackageForInstallMock, - revealLinuxPackage: revealLinuxPackageMock + resolveLinuxPackageRevealTarget: resolveLinuxPackageRevealTargetMock })) const ARTIFACT = { @@ -95,6 +99,16 @@ const ARTIFACT = { path: '/home/tester/.cache/orca-updater/pending/orca-ide_1.0.61_amd64.deb', sha512: 'LHlL7dKoqg98gS2nfQv878dK+UoktbAkm4M20/hoJ2Qr0Kqsa3MSL4VmWy/Lll/MYjQFkpvOxduQ/vswentozA==' } +const MANUAL_INSTALL_STATUS = { + state: 'error', + message: 'Quit Orca before running the system package install command.', + recovery: { + kind: 'linux-package-install', + packageType: 'deb', + reason: 'manual-install-required', + version: '1.0.61' + } +} as const satisfies UpdateStatus warmUpdaterModule() @@ -108,7 +122,7 @@ describe('linux package recovery actions', () => { vi.useFakeTimers() resetHandlers() autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null) - autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([]) autoUpdaterMock.quitAndInstall.mockReset() autoUpdaterMock.setFeedURL.mockReset() autoUpdaterMock.on.mockClear() @@ -119,8 +133,10 @@ describe('linux package recovery actions', () => { resolveLinuxPackageInstallInstructionsMock .mockReset() .mockResolvedValue({ ok: true, command: "sudo apt install -- ''", packageFileName: 'p' }) - revalidateLinuxPackageForInstallMock.mockReset().mockResolvedValue({ ok: true }) - revealLinuxPackageMock.mockReset().mockResolvedValue({ ok: true }) + resolveLinuxPackageRevealTargetMock + .mockReset() + .mockResolvedValue({ ok: true, path: ARTIFACT.path }) + showItemInFolderMock.mockReset() }) const startUpdater = async (): Promise<{ @@ -135,13 +151,19 @@ describe('linux package recovery actions', () => { return { send, updater } } - /** Drives a pre-commit install failure so the status carries the recovery discriminant. */ - const failInstall = async (updater: typeof UpdaterModule): Promise => { - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error('Command failed, exited with code 127')) + const activateRecovery = async ( + updater: typeof UpdaterModule, + version = '1.0.61' + ): Promise => { + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version })) + return Promise.resolve(null) }) - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + updater.downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version }) } type ErrorStatus = Extract @@ -162,12 +184,12 @@ describe('linux package recovery actions', () => { 'No package install recovery is available.' ) expect(resolveLinuxPackageInstallInstructionsMock).not.toHaveBeenCalled() - expect(revealLinuxPackageMock).not.toHaveBeenCalled() + expect(resolveLinuxPackageRevealTargetMock).not.toHaveBeenCalled() }) - it('revalidates the retained package on every invocation', async () => { + it('validates the retained package on every invocation', async () => { const { updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ ok: true, @@ -181,16 +203,74 @@ describe('linux package recovery actions', () => { const recovery = { kind: 'linux-package-install', packageType: 'deb', - reason: 'package-install-failed', + reason: 'manual-install-required', version: '1.0.61' } expect(resolveLinuxPackageInstallInstructionsMock.mock.calls).toEqual([[recovery], [recovery]]) - expect(revealLinuxPackageMock.mock.calls).toEqual([[recovery], [recovery]]) + expect(resolveLinuxPackageRevealTargetMock.mock.calls).toEqual([[recovery], [recovery]]) + expect(showItemInFolderMock).toHaveBeenCalledTimes(2) + }) + + it('restores recovery after a recheck resolves without a terminal event', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(1_000) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.showLinuxPackage()).resolves.toBeUndefined() + }) + + it('restores recovery after a recheck fails', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('offline')) + send.mockClear() + + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ + ok: true, + command: "sudo apt install -- ''", + packageFileName: 'p' + }) + }) + + it('restores recovery when a pinned check resolves to the current version', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.51' }) + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.showLinuxPackage()).resolves.toBeUndefined() + }) + + it('restores recovery when resolving a pinned check fails', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + send.mockClear() + + updater.checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'not-a-release-tag' }) + await vi.advanceTimersByTimeAsync(0) + + expect(send).toHaveBeenLastCalledWith('updater:status', MANUAL_INSTALL_STATUS) + await expect(updater.getLinuxPackageInstallInstructions()).resolves.toEqual({ + ok: true, + command: "sudo apt install -- ''", + packageFileName: 'p' + }) }) it('replaces the structured status when revalidation fails so stale actions die', async () => { const { send, updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) resolveLinuxPackageInstallInstructionsMock.mockResolvedValue({ ok: false, reason: 'hash-mismatch' @@ -203,6 +283,7 @@ describe('linux package recovery actions', () => { expect(clearTrackedLinuxPackageArtifactMock).toHaveBeenCalledTimes(1) const latest = errorStatuses(send).at(-1) expect(latest?.state === 'error' && latest.recovery).toBeUndefined() + expect(latest?.version).toBe('1.0.61') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'linux_package_recovery_unavailable', { reason: 'hash-mismatch', packageType: 'deb', version: '1.0.61' }, @@ -212,13 +293,13 @@ describe('linux package recovery actions', () => { await expect(updater.showLinuxPackage()).rejects.toThrow( 'No package install recovery is available.' ) - expect(revealLinuxPackageMock).not.toHaveBeenCalled() + expect(resolveLinuxPackageRevealTargetMock).not.toHaveBeenCalled() }) it('clears recovery for both actions once the package is gone', async () => { const { updater } = await startUpdater() - await failInstall(updater) - revealLinuxPackageMock.mockResolvedValue({ ok: false, reason: 'missing' }) + await activateRecovery(updater) + resolveLinuxPackageRevealTargetMock.mockResolvedValue({ ok: false, reason: 'missing' }) await expect(updater.showLinuxPackage()).rejects.toThrow('no longer in the update cache') @@ -231,7 +312,7 @@ describe('linux package recovery actions', () => { 'resolves %s as a result and keeps the card usable instead of rejecting', async (reason) => { const { send, updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) resolveLinuxPackageInstallInstructionsMock.mockResolvedValue({ ok: false, reason }) const statusesBefore = errorStatuses(send).length @@ -256,8 +337,10 @@ describe('linux package recovery actions', () => { it('keeps recovery available after a transient read failure', async () => { const { send, updater } = await startUpdater() - await failInstall(updater) - revealLinuxPackageMock.mockResolvedValue({ ok: false, reason: 'read-failed' }) + await activateRecovery(updater) + showItemInFolderMock.mockImplementationOnce(() => { + throw new Error('no file manager available') + }) const statusesBefore = errorStatuses(send).length await expect(updater.showLinuxPackage()).rejects.toThrow( @@ -267,13 +350,12 @@ describe('linux package recovery actions', () => { // Why: a read error is not evidence the artifact is bad, so retrying must stay possible. expect(clearTrackedLinuxPackageArtifactMock).not.toHaveBeenCalled() expect(errorStatuses(send)).toHaveLength(statusesBefore) - revealLinuxPackageMock.mockResolvedValue({ ok: true }) await expect(updater.showLinuxPackage()).resolves.toBeUndefined() }) - it('ignores a stale mismatch verdict once a newer recovery replaced the card', async () => { + it('ignores a stale mismatch after the same package cycle is captured again', async () => { const { send, updater } = await startUpdater() - await failInstall(updater) + await activateRecovery(updater) let settleValidation!: (result: { ok: false; reason: 'hash-mismatch' }) => void resolveLinuxPackageInstallInstructionsMock.mockReturnValue( new Promise((resolve) => { @@ -283,12 +365,51 @@ describe('linux package recovery actions', () => { const pending = updater.getLinuxPackageInstallInstructions() // A 160 MB hash outlives the cycle it started in; a newer download takes over meanwhile. - getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT, version: '1.0.62' }) - await failInstall(updater) + getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT }) + await activateRecovery(updater) settleValidation({ ok: false, reason: 'hash-mismatch' }) - await expect(pending).rejects.toThrow('no longer matches the verified release') + await expect(pending).rejects.toThrow('Package install recovery is no longer current.') expect(clearTrackedLinuxPackageArtifactMock).not.toHaveBeenCalled() - expect(errorStatuses(send).at(-1)?.recovery?.version).toBe('1.0.62') + expect(errorStatuses(send).at(-1)?.recovery?.version).toBe('1.0.61') + }) + + it('does not return stale instructions after a same-version recapture', async () => { + const { send, updater } = await startUpdater() + await activateRecovery(updater) + let settleValidation!: (result: { ok: true; command: string; packageFileName: string }) => void + resolveLinuxPackageInstallInstructionsMock.mockReturnValue( + new Promise((resolve) => { + settleValidation = resolve + }) + ) + + const pending = updater.getLinuxPackageInstallInstructions() + getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT }) + await activateRecovery(updater) + const statusesBefore = errorStatuses(send).length + settleValidation({ ok: true, command: 'stale command', packageFileName: 'stale.deb' }) + + await expect(pending).rejects.toThrow('Package install recovery is no longer current.') + expect(errorStatuses(send)).toHaveLength(statusesBefore) + }) + + it('does not reveal a stale path after a same-version recapture', async () => { + const { updater } = await startUpdater() + await activateRecovery(updater) + let settleValidation!: (result: { ok: true; path: string }) => void + resolveLinuxPackageRevealTargetMock.mockReturnValue( + new Promise((resolve) => { + settleValidation = resolve + }) + ) + + const pending = updater.showLinuxPackage() + getTrackedLinuxPackageArtifactMock.mockReturnValue({ ...ARTIFACT }) + await activateRecovery(updater) + settleValidation({ ok: true, path: ARTIFACT.path }) + + await expect(pending).rejects.toThrow('Package install recovery is no longer current.') + expect(showItemInFolderMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index e2441d64180..234cdc0b2c4 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -1,9 +1,66 @@ -import { app } from 'electron' +import { app, autoUpdater as nativeUpdater } from 'electron' import type { UpdateStatus } from '../shared/update-status-types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' const MAC_INSTALL_READY_TIMEOUT_MS = 15000 +export function registerMacUpdaterEvents({ + getCurrentStatus, + hasInstallableDownloadedVersion, + getPendingInstallVersion, + getKnownReleaseUrl, + performQuitAndInstall, + shouldDeferMacQuitForInstall, + sendStatus +}: { + getCurrentStatus: () => UpdateStatus + hasInstallableDownloadedVersion: () => boolean + getPendingInstallVersion: () => string + getKnownReleaseUrl: () => string | undefined + performQuitAndInstall: () => void | Promise + shouldDeferMacQuitForInstall: () => boolean + sendStatus: (status: UpdateStatus) => void +}): void { + if (process.platform === 'darwin') { + nativeUpdater.on('update-downloaded', () => { + const hasInstallableVersion = hasInstallableDownloadedVersion() + handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => { + sendStatus({ + state: 'downloaded', + version: getPendingInstallVersion(), + releaseUrl: getKnownReleaseUrl() + }) + }) + }) + } + + app.on('before-quit', (event) => { + if (!shouldDeferMacQuitForInstall()) { + return + } + if (consumeMacInstallGuardBypass()) { + recordUpdaterLifecycle('macos_before_quit_guard_bypassed') + return + } + if (isMacQuitAndInstallInFlight()) { + return + } + if ( + deferMacQuitUntilInstallerReady( + getCurrentStatus(), + hasInstallableDownloadedVersion(), + getPendingInstallVersion, + sendStatus + ) + ) { + recordUpdaterLifecycle('macos_before_quit_deferred', { + version: getPendingInstallVersion() + }) + event.preventDefault() + } + }) +} + /** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */ let squirrelReady = false /** Remembers a user/app quit request that arrived before Squirrel.Mac had a diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts index 4a3315862f3..36687d0a79e 100644 --- a/src/main/updater-test-harness.ts +++ b/src/main/updater-test-harness.ts @@ -4,6 +4,7 @@ import { clearTrackedRealTimers, trackRealTimers } from './updater-test-timer-tr /** Loose spy signature for the electron/electron-updater calls the suites only assert on. */ type UpdaterSpy = Mock<(...args: unknown[]) => unknown> +type LinuxPackageType = 'deb' | 'rpm' | 'non-root' | 'unusable' type AutoUpdaterMock = { autoDownload: boolean @@ -44,7 +45,11 @@ type UpdaterModuleFactories = { electronUpdaterLoader: () => { loadElectronAutoUpdater: () => AutoUpdaterMock } electronToolkitUtils: () => { is: { dev: boolean } } ipcPty: () => { killAllPty: UpdaterSpy } - linuxUpdatePackageType: () => { getLinuxRootPackageType: Mock<() => 'deb' | 'rpm' | null> } + linuxUpdatePackageType: () => { + getLinuxPackageType: Mock<() => LinuxPackageType> + getLinuxRootPackageType: Mock<() => 'deb' | 'rpm' | null> + isExternallyManagedLinuxInstall: Mock<() => boolean> + } updaterLifecycleDiagnostics: () => { recordUpdaterLifecycle: UpdaterSpy } updaterChangelog: () => { fetchChangelog: UpdaterSpy } updaterNudge: () => { fetchNudge: UpdaterSpy; shouldApplyNudge: UpdaterSpy } @@ -68,7 +73,9 @@ export type UpdaterMocks = { isMock: { dev: boolean } killAllPtyMock: UpdaterSpy powerMonitorOnMock: UpdaterSpy + getLinuxPackageTypeMock: Mock<() => LinuxPackageType> getLinuxRootPackageTypeMock: Mock<() => 'deb' | 'rpm' | null> + isExternallyManagedLinuxInstallMock: Mock<() => boolean> recordUpdaterLifecycleMock: UpdaterSpy fetchChangelogMock: UpdaterSpy fetchNudgeMock: UpdaterSpy @@ -206,6 +213,10 @@ export function createUpdaterMocks(): UpdaterMocks { const killAllPtyMock = vi.fn() const powerMonitorOnMock = vi.fn() const getLinuxRootPackageTypeMock = vi.fn<() => 'deb' | 'rpm' | null>(() => null) + const getLinuxPackageTypeMock = vi.fn<() => LinuxPackageType>(() => { + return getLinuxRootPackageTypeMock() ?? 'non-root' + }) + const isExternallyManagedLinuxInstallMock = vi.fn<() => boolean>(() => false) const recordUpdaterLifecycleMock = vi.fn() const fetchChangelogMock = vi.fn() const fetchNudgeMock = vi.fn() @@ -232,7 +243,11 @@ export function createUpdaterMocks(): UpdaterMocks { electronToolkitUtils: () => ({ is: isMock }), ipcPty: () => ({ killAllPty: killAllPtyMock }), // Why: only the marker resolver is faked so the real artifact capture/redaction path stays under test. - linuxUpdatePackageType: () => ({ getLinuxRootPackageType: getLinuxRootPackageTypeMock }), + linuxUpdatePackageType: () => ({ + getLinuxPackageType: getLinuxPackageTypeMock, + getLinuxRootPackageType: getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstall: isExternallyManagedLinuxInstallMock + }), updaterLifecycleDiagnostics: () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock }), updaterChangelog: () => ({ fetchChangelog: fetchChangelogMock }), updaterNudge: () => ({ fetchNudge: fetchNudgeMock, shouldApplyNudge: shouldApplyNudgeMock }), @@ -276,6 +291,10 @@ export function createUpdaterMocks(): UpdaterMocks { disarmExitWatchdogMock.mockReset() powerMonitorOnMock.mockReset() getLinuxRootPackageTypeMock.mockReset().mockReturnValue(null) + getLinuxPackageTypeMock.mockReset().mockImplementation(() => { + return getLinuxRootPackageTypeMock() ?? 'non-root' + }) + isExternallyManagedLinuxInstallMock.mockReset().mockReturnValue(false) recordUpdaterLifecycleMock.mockReset() fetchNudgeMock.mockReset().mockResolvedValue(null) shouldApplyNudgeMock.mockReset().mockReturnValue(false) @@ -306,7 +325,9 @@ export function createUpdaterMocks(): UpdaterMocks { isMock, killAllPtyMock, powerMonitorOnMock, + getLinuxPackageTypeMock, getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstallMock, recordUpdaterLifecycleMock, fetchChangelogMock, fetchNudgeMock, diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 9cbcc97c85e..767fef421e3 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -86,6 +86,23 @@ describe('statusesEqual', () => { ).toBe(false) expect(statusesEqual(withRecovery, { ...withRecovery })).toBe(true) }) + + it('delivers a same-valued recovery recaptured for a new package cycle', () => { + expect(statusesEqual(withRecovery, { ...withRecovery, recovery: { ...recovery } })).toBe(false) + }) + + it('separates generic errors by version and retryability', () => { + const error: UpdateStatus = { + state: 'error', + message: 'package unavailable', + version: '1.0.61', + retryable: false + } + + expect(statusesEqual(error, { ...error, version: '1.0.62' })).toBe(false) + expect(statusesEqual(error, { ...error, retryable: true })).toBe(false) + expect(statusesEqual(error, { ...error })).toBe(true) + }) }) describe('isReleaseAssetsPublishingFailure', () => { diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index 08e2f519861..bae6474cc66 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -77,6 +77,11 @@ vi.mock('electron', () => ({ vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) vi.mock('./electron-updater-loader', () => ({ loadElectronAutoUpdater: () => autoUpdaterMock })) +vi.mock('./linux-update-package-type', () => ({ + getLinuxPackageType: () => 'non-root', + getLinuxRootPackageType: () => null, + isExternallyManagedLinuxInstall: () => false +})) vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } })) vi.mock('./ipc/pty', () => ({ killAllPty: killAllPtyMock })) vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) })) @@ -166,6 +171,7 @@ describe('headless serve update install handoff', () => { checkForUpdatesFromMenu() await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.emit('download-progress', { percent: 100 }) autoUpdaterMock.emit('update-downloaded', { version: pendingInstaller.version }) const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find( ([event]) => event === 'update-downloaded' diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index 6344a79d13b..bef63d6912a 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -101,6 +101,11 @@ vi.mock('./updater-nudge', () => ({ vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock })) +vi.mock('./linux-update-package-type', () => ({ + getLinuxPackageType: () => 'non-root', + getLinuxRootPackageType: () => null, + isExternallyManagedLinuxInstall: () => false +})) // The real electron-updater DebUpdater failure text when elevation is impossible. const DEB_ELEVATION_ERROR = @@ -155,6 +160,8 @@ async function reachDownloaded(): Promise { autoUpdaterMock.emit('checking-for-update') autoUpdaterMock.emit('update-available', { version: '1.4.163' }) await new Promise((resolve) => setTimeout(resolve, 0)) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + updater.downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.4.163' }) expect(updater.getUpdateStatus().state).toBe('downloaded') return updater diff --git a/src/main/updater.linux-externally-managed.test.ts b/src/main/updater.linux-externally-managed.test.ts new file mode 100644 index 00000000000..0b63a8ec9ed --- /dev/null +++ b/src/main/updater.linux-externally-managed.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as UpdaterModule from './updater' +import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' +import type { LinuxRootPackageType, UpdateStatus } from '../shared/update-status-types' + +const { + autoUpdaterMock, + getLinuxRootPackageTypeMock, + isExternallyManagedLinuxInstallMock, + recordUpdaterLifecycleMock, + fetchNewerReleaseTagsMock, + moduleFactories, + resetUpdaterMocks +} = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) + +vi.mock('electron', () => moduleFactories.electron()) +vi.mock('electron-updater', () => moduleFactories.electronUpdater()) +vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) +vi.mock('@electron-toolkit/utils', () => moduleFactories.electronToolkitUtils()) +vi.mock('./ipc/pty', () => moduleFactories.ipcPty()) +vi.mock('./linux-update-package-type', () => moduleFactories.linuxUpdatePackageType()) +vi.mock('./updater-lifecycle-diagnostics', () => moduleFactories.updaterLifecycleDiagnostics()) +vi.mock('./updater-changelog', () => moduleFactories.updaterChangelog()) +vi.mock('./updater-nudge', () => moduleFactories.updaterNudge()) +vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExitWatchdog()) +vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) +vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) +vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) + +const EXTERNALLY_MANAGED_MESSAGE = + 'This copy of Orca is managed by your system package manager, so Orca cannot install updates itself. Update Orca through your distribution instead.' + +/** #17702: a repackaged install (AUR, Nix, container rebuild) inherits the .deb `package-type` + * marker but has no package manager that can apply an Orca-downloaded package. */ +warmUpdaterModule() + +describe('updater externally managed Linux installs', () => { + beforeEach(() => { + resetUpdaterMocks() + }) + + async function startUpdater(options: { + packageType: LinuxRootPackageType | null + externallyManaged: boolean + }): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> { + getLinuxRootPackageTypeMock.mockReturnValue(options.packageType) + isExternallyManagedLinuxInstallMock.mockReturnValue(options.externallyManaged) + vi.useFakeTimers() + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const updater = await loadUpdaterModule() + updater.setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + installMode: 'interactive' + }) + return { send, updater } + } + + function lastStatus(send: ReturnType): UpdateStatus | undefined { + return send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] + } + + it('still reports the available release so the user can update through their distribution', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + expect(lastStatus(send)).toEqual({ + state: 'available', + version: '1.0.61', + changelog: null, + externallyManaged: true + }) + }) + + it('does not flag a real deb host', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: false }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + const status = lastStatus(send) + expect(status).toEqual({ state: 'available', version: '1.0.61', changelog: null }) + expect(status && 'externallyManaged' in status).toBe(false) + }) + + it('refuses the download instead of spending it on a package it can never install', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + expect(lastStatus(send)).toEqual({ + state: 'error', + message: EXTERNALLY_MANAGED_MESSAGE, + version: '1.0.61', + retryable: false + }) + }) + + it('marks the refusal non-retryable so the card offers no Retry Download', async () => { + const { send, updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + const status = lastStatus(send) + expect(status?.state === 'error' && status.retryable).toBe(false) + }) + + it('records the blocked download for field diagnosis', async () => { + const { updater } = await startUpdater({ packageType: 'deb', externallyManaged: true }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'linux_package_externally_managed_download_blocked', + { version: '1.0.61' } + ) + }) + + it('leaves an ordinary deb host able to download', async () => { + const { updater } = await startUpdater({ packageType: 'deb', externallyManaged: false }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalled() + }) + + it('leaves an AppImage host able to download', async () => { + const { updater } = await startUpdater({ packageType: null, externallyManaged: false }) + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + + updater.downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalled() + }) +}) diff --git a/src/main/updater.linux-root-package-install.test.ts b/src/main/updater.linux-root-package-install.test.ts index b52bf40f4c8..01f489bd8ef 100644 --- a/src/main/updater.linux-root-package-install.test.ts +++ b/src/main/updater.linux-root-package-install.test.ts @@ -1,12 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createHash } from 'node:crypto' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' +import { tmpdir } from 'node:os' import type * as UpdaterModule from './updater' -import type * as RecoveryModule from './linux-package-update-recovery' -import type { UpdateStatus } from '../shared/update-status-types' -import { PRE_COMMIT_INSTALL_FAILURE } from './updater-test-harness' +import type { LinuxRootPackageType, UpdateStatus } from '../shared/update-status-types' import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader' const { @@ -14,10 +10,9 @@ const { nativeUpdaterMock, autoUpdaterMock, killAllPtyMock, + getLinuxPackageTypeMock, getLinuxRootPackageTypeMock, recordUpdaterLifecycleMock, - armExitWatchdogMock, - disarmExitWatchdogMock, fetchNewerReleaseTagsMock, moduleFactories, resetUpdaterMocks @@ -37,687 +32,222 @@ vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) -type RevalidationVerdict = Awaited< - ReturnType -> +const packageSha512 = Buffer.alloc(64).toString('base64') -// Captured before any vi.useFakeTimers() call: the only handle left that still yields to libuv. -const realSetTimeout = globalThis.setTimeout - -type StagedLinuxPackages = { - cacheRoot: string - debPath: string - debSha512: string - rpmPath: string - rpmSha512: string -} - -/** - * Stages real packages inside a real updater cache: every install re-proves the retained digest by - * streaming the file off disk, so a path that never existed would abort before reaching the native - * updater. Returns the actual digests for the download events. - */ -function stageLinuxUpdateCache(): StagedLinuxPackages { - const cacheRoot = mkdtempSync(join(tmpdir(), 'orca-updater-cache-')) - const pendingDir = join(cacheRoot, 'orca-updater', 'pending') - mkdirSync(pendingDir, { recursive: true }) - const stagePackage = (fileName: string): { path: string; sha512: string } => { - const packagePath = join(pendingDir, fileName) - const bytes = Buffer.from(`orca test package ${fileName}`) - writeFileSync(packagePath, bytes) - return { path: packagePath, sha512: createHash('sha512').update(bytes).digest('base64') } - } - const deb = stagePackage('orca-ide_1.0.61_amd64.deb') - const rpm = stagePackage('orca-ide-1.0.61.x86_64.rpm') +function downloadedEvent(packageType: LinuxRootPackageType): Record { + const fileName = + packageType === 'deb' ? 'orca-ide_1.0.61_amd64.deb' : 'orca-ide-1.0.61.x86_64.rpm' return { - cacheRoot, - debPath: deb.path, - debSha512: deb.sha512, - rpmPath: rpm.path, - rpmSha512: rpm.sha512 - } -} - -type RevalidationProbe = { - /** Switch to held mode, where a verdict only lands when the test says so. Must precede startUpdater. */ - hold: () => void - settle: (verdict: RevalidationVerdict) => void - fail: (error: Error) => void - invocationCount: () => number - /** Resolves once every re-proof this test started has finished. */ - drain: () => Promise -} - -/** One outstanding re-proof; `awaitable` stays false while a held verdict has no way to settle. */ -type OutstandingRevalidation = { promise: Promise; awaitable: boolean } - -/** - * Wraps the pre-install re-proof so tests can await the real disk read instead of budgeting - * event-loop turns, and can hold a verdict open at an exact point in the cycle. Only that one call - * is wrapped — the artifact state stays real. - */ -function probeRevalidation(): RevalidationProbe { - type Artifact = Parameters[0] - let held = false - let invocationCount = 0 - let pending: { - resolve: (verdict: RevalidationVerdict) => void - reject: (error: Error) => void - entry: OutstandingRevalidation - } | null = null - const outstanding: OutstandingRevalidation[] = [] - - const track = (verdict: Promise, awaitable: boolean) => { - const noop = (): void => undefined - const entry: OutstandingRevalidation = { promise: verdict.then(noop, noop), awaitable } - outstanding.push(entry) - return entry - } - - vi.doMock('./linux-package-update-recovery', async () => { - const actual = await vi.importActual('./linux-package-update-recovery') - return { - ...actual, - revalidateLinuxPackageForInstall: vi.fn((artifact: Artifact) => { - invocationCount += 1 - if (!held) { - const verdict = actual.revalidateLinuxPackageForInstall(artifact) - track(verdict, true) - return verdict - } - let resolve!: (verdict: RevalidationVerdict) => void - let reject!: (error: Error) => void - const verdict = new Promise((res, rej) => { - resolve = res - reject = rej - }) - pending = { resolve, reject, entry: track(verdict, false) } - return verdict - }) - } - }) - - const release = (): typeof pending => { - const current = pending - if (current) { - current.entry.awaitable = true - pending = null - } - return current - } - - return { - hold: () => { - held = true - }, - settle: (verdict) => release()?.resolve(verdict), - fail: (error) => release()?.reject(error), - invocationCount: () => invocationCount, - drain: async () => { - // A verdict still held open can never settle on its own, so draining skips it. - let ready = outstanding.filter((entry) => entry.awaitable) - while (ready.length > 0) { - for (const entry of ready) { - outstanding.splice(outstanding.indexOf(entry), 1) - } - await Promise.all(ready.map((entry) => entry.promise)) - ready = outstanding.filter((entry) => entry.awaitable) - } - } + version: '1.0.61', + downloadedFile: join(tmpdir(), 'orca-updater', 'pending', fileName), + files: [{ url: fileName, sha512: packageSha512 }] } } warmUpdaterModule() -describe('updater', () => { +describe('updater Linux root packages', () => { beforeEach(() => { resetUpdaterMocks() }) - describe('linux root package install recovery', () => { - let staged: StagedLinuxPackages - let EXIT_127: string - let revalidation: RevalidationProbe + async function startUpdater( + packageType: LinuxRootPackageType | null, + installMode: UpdaterModule.UpdateInstallMode = 'interactive' + ): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> { + getLinuxRootPackageTypeMock.mockReturnValue(packageType) + vi.useFakeTimers() + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const updater = await loadUpdaterModule() + updater.setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + installMode + }) + return { send, updater } + } - // Why: the quit timer needs fake time, and the work it starts needs real event-loop turns — - // fake timers never advance libuv. The re-proof itself is awaited rather than counted out - // (#15243): its disk read is wall-clock bound, so a loaded runner outlasts any turn budget and - // the tail lands in the next test. - const settleQuitAndInstall = async (): Promise => { - await vi.advanceTimersByTimeAsync(100) - await revalidation.drain() - // Full Node 26 shards can briefly starve the libuv poll phase while other workers transform - // tests; keep the operation alive long enough to avoid leaking it into the next test. - for (let turn = 0; turn < 200; turn += 1) { - await new Promise((resolve) => realSetTimeout(resolve, 0)) - } - await vi.advanceTimersByTimeAsync(0) + function lastStatus(send: ReturnType): UpdateStatus | undefined { + return send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] + } + + function markMacInstallerReady(): void { + if (process.platform !== 'darwin') { + return } + const handler = nativeUpdaterMock.on.mock.calls.find( + ([eventName]) => eventName === 'update-downloaded' + )?.[1] as (() => void) | undefined + handler?.() + } - beforeEach(() => { - staged = stageLinuxUpdateCache() - vi.stubEnv('XDG_CACHE_HOME', staged.cacheRoot) - EXIT_127 = `Command failed: /usr/bin/pkexec /usr/bin/dpkg -i ${staged.debPath}, exited with code 127` - revalidation = probeRevalidation() - }) - - afterEach(async () => { - // Why: an unfinished re-proof keeps running against this test's module instance, whose mocks - // are the same singletons the next test asserts on — it would double every install-path count. - await revalidation.drain() - vi.doUnmock('./linux-package-update-recovery') - vi.unstubAllEnvs() - rmSync(staged.cacheRoot, { recursive: true, force: true }) - }) - - const lastStatus = (send: ReturnType): UpdateStatus | undefined => - send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] - - const PRE_COMMIT_FAILURE_MESSAGE = PRE_COMMIT_INSTALL_FAILURE - const AGENT_STDERR = - 'pkexec: Error executing command as another user: No authentication agent found.' - - const downloadedEvent = (overrides?: Record): Record => ({ - version: '1.0.61', - downloadedFile: staged.debPath, - files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: staged.debSha512 }], - ...overrides - }) - - const rpmDownloadedEvent = (): Record => - downloadedEvent({ - downloadedFile: staged.rpmPath, - files: [{ url: 'orca-ide-1.0.61.x86_64.rpm', sha512: staged.rpmSha512 }] - }) - - const startUpdater = async ( - packageType: 'deb' | 'rpm' | null - ): Promise<{ send: ReturnType; updater: typeof UpdaterModule }> => { - getLinuxRootPackageTypeMock.mockReturnValue(packageType) - vi.useFakeTimers() - fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' }) - autoUpdaterMock.checkForUpdates.mockImplementation(() => { - autoUpdaterMock.emit('checking-for-update') - queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' })) - return Promise.resolve(undefined) - }) - const send = vi.fn() - const updater = await loadUpdaterModule() - updater.setupAutoUpdater({ webContents: { send } } as never, { - getLastUpdateCheckAt: () => Date.now() - }) - return { send, updater } + async function reachDownloaded( + updater: typeof UpdaterModule, + event: Record, + markInstallerReady = false + ): Promise { + updater.checkForUpdatesFromMenu() + await vi.advanceTimersByTimeAsync(0) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + updater.downloadUpdate() + autoUpdaterMock.emit('update-downloaded', event) + if (markInstallerReady) { + markMacInstallerReady() } + await vi.advanceTimersByTimeAsync(0) + } - const reachDownloaded = async ( - updater: typeof UpdaterModule, - event: Record - ): Promise => { - updater.checkForUpdatesFromMenu() - await vi.advanceTimersByTimeAsync(0) - autoUpdaterMock.emit('update-downloaded', event) - if (process.platform === 'darwin') { - const nativeReady = nativeUpdaterMock.on.mock.calls.find( - ([eventName]) => eventName === 'update-downloaded' - )?.[1] as (() => void) | undefined - nativeReady?.() - } - await vi.advanceTimersByTimeAsync(0) - } - - it('disables install-on-quit for deb and rpm root packages', async () => { - for (const packageType of ['deb', 'rpm'] as const) { - vi.resetModules() - autoUpdaterMock.autoInstallOnAppQuit = true - getLinuxRootPackageTypeMock.mockReturnValue(packageType) - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { - getLastUpdateCheckAt: () => Date.now(), - installMode: 'interactive' - }) - - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) - } - }) - - it('keeps interactive install-on-quit when no root-package marker is present', async () => { - autoUpdaterMock.autoInstallOnAppQuit = false - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { - getLastUpdateCheckAt: () => Date.now(), - installMode: 'interactive' - }) - - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) - }) - - it('leaves headless serve installs supervisor-controlled', async () => { - for (const installMode of [ - 'supervised-headless-serve', - 'unsupported-headless-serve' - ] as const) { - vi.resetModules() - autoUpdaterMock.autoInstallOnAppQuit = true - const { setupAutoUpdater } = await loadUpdaterModule() - - setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { - getLastUpdateCheckAt: () => Date.now(), - installMode - }) - - expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) - } - }) - - it('sends structured recovery when quitAndInstall throws synchronously', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) - throw new Error(EXIT_127) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - // Why: the sync throw ends capture before the catch, so the stashed text must survive. - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: `${AGENT_STDERR} target `, - recovery: { - kind: 'linux-package-install', - packageType: 'deb', - reason: 'authentication-agent-unavailable', - version: '1.0.61' - } - }) - }) - - it('recovers an event-driven pre-commit failure without tearing down the session', async () => { + it.each(['deb', 'rpm'] as const)( + 'hands off %s installs without invoking the native updater', + async (packageType) => { const openWindow = { removeAllListeners: vi.fn() } browserWindowMock.getAllWindows.mockReturnValue([openWindow] as never) - const { send, updater } = await startUpdater('rpm') - await reachDownloaded(updater, rpmDownloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error('Command failed, exited with code 1')) - }) + const { send, updater } = await startUpdater(packageType) - updater.quitAndInstall() - await settleQuitAndInstall() + await reachDownloaded(updater, downloadedEvent(packageType)) - expect(send).toHaveBeenCalledWith('updater:status', { + expect(lastStatus(send)).toEqual({ state: 'error', - message: 'Command failed, exited with code 1', + message: 'Quit Orca before running the system package install command.', recovery: { kind: 'linux-package-install', - packageType: 'rpm', - reason: 'package-install-failed', + packageType, + reason: 'manual-install-required', version: '1.0.61' } }) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() expect(killAllPtyMock).not.toHaveBeenCalled() expect(openWindow.removeAllListeners).not.toHaveBeenCalled() expect(updater.isQuittingForUpdate()).toBe(false) - expect(disarmExitWatchdogMock).toHaveBeenCalled() - }) - - it('keeps the generic install-failure copy when no artifact was retained', async () => { - const { send, updater } = await startUpdater('deb') - // Release metadata without a digest must not enable cached-package recovery. - await reachDownloaded( - updater, - downloadedEvent({ files: [{ url: 'orca-ide_1.0.61_amd64.deb' }] }) - ) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: `${PRE_COMMIT_FAILURE_MESSAGE} (${EXIT_127})` - }) - expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( - 'linux_package_install_failed', - expect.anything(), - expect.anything() - ) - }) - - it('advises a restart only for a failure before the native invoke', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - // The source calls this out as the pre-native "cleanup/tracing exception" case. - recordUpdaterLifecycleMock.mockImplementation((event: unknown) => { - if (event === 'quit_and_install_invoking_native') { - throw new Error('tracing sink unavailable') - } - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: 'Could not restart to install the update. Quit and reopen Orca, then try again.' - }) - expect(updater.isQuittingForUpdate()).toBe(false) - }) - - it('keeps a committed install intact when post-commit cleanup throws', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - // Why: spawnSync already installed the package; a teardown throw must not be reported as failure. - killAllPtyMock.mockImplementation(() => { - throw new Error('pty teardown failed') - }) - send.mockClear() - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'post_commit_cleanup_failed', - { errorType: 'Error' }, - expect.objectContaining({ level: 'warn' }) - ) - expect(send).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(true) - expect(armExitWatchdogMock).toHaveBeenCalledTimes(1) - expect(disarmExitWatchdogMock).not.toHaveBeenCalled() - }) - - it('still suppresses late post-commit errors while an artifact is retained', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - await settleQuitAndInstall() - expect(killAllPtyMock).toHaveBeenCalledTimes(1) - - send.mockClear() - autoUpdaterMock.emit('error', new Error(EXIT_127)) - - expect(send).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(true) - }) - - it('retries the automatic install without redownloading the package', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - updater.quitAndInstall() - await settleQuitAndInstall() - - // Why: a retry usually fails identically; a deduped status would strand the preload restart relay. - expect( - send.mock.calls.filter( - ([channel, status]) => - channel === 'updater:status' && - (status as { recovery?: { kind?: string } })?.recovery?.kind === 'linux-package-install' - ) - ).toHaveLength(2) - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(2) - expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: 'deb', - version: '1.0.61' - }) - }) - - // Why: the cache path is user-writable, so the bytes verified when the recovery card - // rendered are not necessarily the bytes a root package manager would read on retry. - it('aborts the retry when the retained package no longer matches its digest', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - // The escalation fails, which is what puts the recovery card (and its retry) on screen. - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - updater.quitAndInstall() - await settleQuitAndInstall() - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - - // A local process swaps the verified package for its own between failure and retry. - writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) - send.mockClear() - killAllPtyMock.mockClear() - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - expect(killAllPtyMock).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(false) - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' - }) - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.objectContaining({ action: 'retry-automatic', reason: 'hash-mismatch' }), - expect.anything() - ) - }) - - // Why: "Restart to Update" is the common path and can sit unclicked for hours, so the same - // user-writable package reaches a root installer with a far longer window than any retry. - it('aborts the first install when the downloaded package was swapped', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - writeFileSync(staged.debPath, Buffer.from('attacker supplied package')) - send.mockClear() - - updater.quitAndInstall() - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(killAllPtyMock).not.toHaveBeenCalled() - expect(updater.isQuittingForUpdate()).toBe(false) - expect(send).toHaveBeenCalledWith('updater:status', { - state: 'error', - message: - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.' - }) expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }), - expect.anything() + 'linux_package_manual_install_required', + { packageType, version: '1.0.61' } ) - }) + } + ) - it('installs normally when the retained package still matches its digest', async () => { - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) + it('guards the native boundary even when no package artifact was retained', async () => { + const { send, updater } = await startUpdater('deb') - updater.quitAndInstall() - await settleQuitAndInstall() + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - expect(killAllPtyMock).toHaveBeenCalledTimes(1) - // Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer - // prompt against the install that is already committed. - expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') - expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.anything(), - expect.anything() - ) - }) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + }) - // Why: hashing 160 MB outlives the cycle it started in, and Check for Updates stays enabled - // while it runs — a verdict from the old cycle must not replace the card that took over. - it('drops an abort verdict once a newer check replaced the card', async () => { - revalidation.hold() - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - // The user gives up waiting and checks again; that check owns the card from here. - updater.checkForUpdatesFromMenu() - await vi.advanceTimersByTimeAsync(0) - expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) - - revalidation.settle({ ok: false, reason: 'hash-mismatch' }) - await settleQuitAndInstall() - - // The install is still abandoned — only the stale status is withheld. - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' }) - // Withholding the status must not also withhold the abort: the renderer armed its restart and - // would otherwise skip its unsaved-work prompt for the rest of the session. - expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') - expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( - 'linux_package_revalidation_failed', - expect.objectContaining({ reason: 'hash-mismatch' }), - expect.anything() - ) - }) - - // Why: EMFILE/EIO during the stream says nothing about the bytes, so the copy must not claim - // the package changed and the card must keep the actions that still work. - it('keeps the recovery card usable when the re-proof cannot read the package', async () => { - revalidation.hold() - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.settle({ ok: true }) - await settleQuitAndInstall() - send.mockClear() - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.settle({ ok: false, reason: 'read-failed' }) - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - expect(lastStatus(send)).toEqual({ - state: 'error', - message: - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', - recovery: { - kind: 'linux-package-install', - packageType: 'deb', - reason: 'package-install-failed', - version: '1.0.61' - } - }) - }) - - // Why: the re-proof runs before performQuitAndInstall's own error handling, so a rejection - // there would strand the quit timer and make every later install a silent no-op. - it('stays installable after a re-proof that rejects outright', async () => { - revalidation.hold() - const { send, updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.fail(new Error('hash worker crashed')) - await settleQuitAndInstall() - - // Fails closed: an unprovable package is not handed to a root package manager. - expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() - expect(lastStatus(send)).toMatchObject({ - state: 'error', - message: - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.' - }) - - updater.quitAndInstall() - await vi.advanceTimersByTimeAsync(100) - revalidation.settle({ ok: true }) - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - }) - - // Why: a second click during the multi-second hash must not schedule a parallel install. - it('ignores a second install request while the digest re-proof runs', async () => { - revalidation.hold() - const { updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - - updater.quitAndInstall() - // Fires the quit timer, which starts the re-proof; its verdict is still outstanding. - await vi.advanceTimersByTimeAsync(100) - expect(revalidation.invocationCount()).toBe(1) - updater.quitAndInstall() - // Advancing here proves the second request never scheduled its own quit timer. - await vi.advanceTimersByTimeAsync(100) - expect(revalidation.invocationCount()).toBe(1) - revalidation.settle({ ok: true }) - await settleQuitAndInstall() - - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) - }) - - it('records classification-only lifecycle data for a package install failure', async () => { - const { updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.logger?.error(`${AGENT_STDERR} target ${staged.debPath}`) - autoUpdaterMock.emit('error', new Error(EXIT_127)) - }) - - updater.quitAndInstall() - await settleQuitAndInstall() - - const failure = recordUpdaterLifecycleMock.mock.calls.find( - ([event]) => event === 'linux_package_install_failed' - ) - expect(failure?.[1]).toEqual({ - packageType: 'deb', - reason: 'authentication-agent-unavailable', - exitCode: 127, + it('preserves the normal AppImage install path when no root-package marker is present', async () => { + const { send, updater } = await startUpdater(null) + await reachDownloaded( + updater, + { version: '1.0.61', - errorType: 'Error' - }) - const durable = JSON.stringify(recordUpdaterLifecycleMock.mock.calls) - expect(durable).not.toContain(staged.debPath) - expect(durable).not.toContain('authentication agent') - }) + downloadedFile: join(tmpdir(), 'Orca-1.0.61.AppImage'), + files: [] + }, + true + ) - it('omits exitCode from lifecycle data when the child status is unparseable', async () => { - const { updater } = await startUpdater('deb') - await reachDownloaded(updater, downloadedEvent()) - autoUpdaterMock.quitAndInstall.mockImplementation(() => { - autoUpdaterMock.emit('error', new Error('dpkg was interrupted')) - }) + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) - updater.quitAndInstall() - await settleQuitAndInstall() - - const failure = recordUpdaterLifecycleMock.mock.calls.find( - ([event]) => event === 'linux_package_install_failed' + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + expect(killAllPtyMock).toHaveBeenCalledTimes(1) + expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect( + send.mock.calls.some( + ([channel, status]) => + channel === 'updater:status' && + (status as UpdateStatus).state === 'error' && + (status as Extract).recovery?.kind === + 'linux-package-install' ) - // Why: an absent key, not an explicit null, keeps the breadcrumb schema honest. - expect(failure?.[1]).toEqual({ - packageType: 'deb', - reason: 'package-install-failed', - version: '1.0.61', - errorType: 'Error' + ).toBe(false) + }) + + it.each(['deb', 'rpm'] as const)( + 'disables install-on-quit and remote automatic control for %s builds', + async (packageType) => { + autoUpdaterMock.autoInstallOnAppQuit = true + const { updater } = await startUpdater(packageType) + + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(updater.getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: false, + reason: 'manual-service-update-required' }) - expect(Object.keys(failure?.[1] as object)).not.toContain('exitCode') + expect(() => updater.checkForRemoteServerUpdate('runtime-1')).toThrow( + 'remote_update_manual_required' + ) + } + ) + + it('keeps interactive install-on-quit and remote control for non-root packages', async () => { + autoUpdaterMock.autoInstallOnAppQuit = false + const { updater } = await startUpdater(null) + + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true) + expect(updater.getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: true, + reason: 'available' }) }) + + it('fails closed for an unusable packaged marker', async () => { + getLinuxPackageTypeMock.mockReturnValue('unusable') + getLinuxRootPackageTypeMock.mockReturnValue(null) + autoUpdaterMock.autoInstallOnAppQuit = true + const { send, updater } = await startUpdater(null) + + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + expect(updater.getRemoteServerUpdateSupport()).toEqual({ + installMode: 'interactive', + automatic: false, + reason: 'manual-service-update-required' + }) + + await reachDownloaded(updater, { + version: '1.0.61', + downloadedFile: join(tmpdir(), 'orca-updater', 'pending', 'orca-ide_1.0.61_amd64.deb'), + files: [{ url: 'orca-ide_1.0.61_amd64.deb', sha512: packageSha512 }] + }) + expect(lastStatus(send)).toEqual({ + state: 'error', + message: + 'Orca could not verify the installed Linux package format, so it will not install this update automatically. Download the update from the official release page and install it manually.', + version: '1.0.61', + retryable: false + }) + + updater.quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + }) + + it('leaves headless serve installs supervisor-controlled', async () => { + for (const installMode of [ + 'supervised-headless-serve', + 'unsupported-headless-serve' + ] as const) { + resetUpdaterMocks() + autoUpdaterMock.autoInstallOnAppQuit = true + await startUpdater(null, installMode) + expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false) + } + }) }) diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index e4fe26296a0..563b8562fd6 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -134,6 +134,7 @@ describe('updater mac install handoff', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) vi.unstubAllGlobals() vi.useRealTimers() }) @@ -145,7 +146,7 @@ describe('updater mac install handoff', () => { const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - const { setupAutoUpdater } = await loadUpdaterModule() + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) await vi.waitFor(() => { @@ -156,6 +157,7 @@ describe('updater mac install handoff', () => { // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. await new Promise((r) => setTimeout(r, 0)) + downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) const preventDefault = vi.fn() @@ -197,7 +199,7 @@ describe('updater mac install handoff', () => { const mainWindow = { webContents: { send: vi.fn() } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - const { setupAutoUpdater, quitAndInstall } = await loadUpdaterModule() + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never, { onBeforeQuit }) await vi.waitFor(() => { @@ -206,6 +208,7 @@ describe('updater mac install handoff', () => { autoUpdaterMock.emit('checking-for-update') autoUpdaterMock.emit('update-available', { version: '1.0.61' }) await vi.advanceTimersByTimeAsync(0) + downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) const preventDefault = vi.fn() @@ -279,7 +282,7 @@ describe('updater mac install handoff', () => { const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) - const { setupAutoUpdater } = await loadUpdaterModule() + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) await vi.waitFor(() => { @@ -290,6 +293,7 @@ describe('updater mac install handoff', () => { // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. await vi.advanceTimersByTimeAsync(0) + downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) const preventDefault = vi.fn() diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 0d381ea473a..0080db58991 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -290,6 +290,7 @@ describe('updater', () => { }) }) + autoUpdaterMock.emit('download-progress', { percent: 100 }) autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) // Why: on macOS install commits only once Squirrel is ready; mark it ready so this test covers the post-commit path on all platforms. diff --git a/src/main/updater.startup-scheduling.test.ts b/src/main/updater.startup-scheduling.test.ts index 46190de47da..ef72af27def 100644 --- a/src/main/updater.startup-scheduling.test.ts +++ b/src/main/updater.startup-scheduling.test.ts @@ -31,6 +31,7 @@ warmUpdaterModule() describe('updater', () => { beforeEach(() => { resetUpdaterMocks() + vi.useFakeTimers() }) it('does not load or configure electron-updater during dev setup', async () => { diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts index 63222fb3101..a533b776a84 100644 --- a/src/main/updater/updater-build-selection.ts +++ b/src/main/updater/updater-build-selection.ts @@ -111,7 +111,7 @@ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { try { const target = resolveTargetBuild(channel, tag) if (compareVersions(target.version, app.getVersion()) === 0) { - this.sendStatus({ state: 'not-available', userInitiated: true }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated: true }) return } this.closeLocalBuildFeed() @@ -140,7 +140,7 @@ export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { this.userInitiatedCheck = false this.clearAvailableUpdateContext() this.restoreReleaseUpdateSource() - this.sendStatus({ + this.sendSettledCheckStatus({ state: 'error', message: String((error as Error)?.message ?? error), userInitiated: true diff --git a/src/main/updater/updater-check-failure.ts b/src/main/updater/updater-check-failure.ts index 8ee2500c6a9..742897af6fd 100644 --- a/src/main/updater/updater-check-failure.ts +++ b/src/main/updater/updater-check-failure.ts @@ -34,7 +34,7 @@ export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { // Why: a failed pinned jump must hand the feed back before surfacing the error, or the pin blocks background checks for the process lifetime. this.clearAvailableUpdateContext() this.restoreReleaseUpdateSource() - this.sendStatus({ state: 'error', message, userInitiated }) + this.sendSettledCheckStatus({ state: 'error', message, userInitiated }) return } const failureKey = this.getCheckFailureKey(message, userInitiated) @@ -80,18 +80,19 @@ export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) if (userInitiated) { // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - this.sendErrorStatus( - this.isStableReleaseNotReadyFailure(sourceError) + this.sendSettledCheckStatus({ + state: 'error', + message: this.isStableReleaseNotReadyFailure(sourceError) ? "A newer release isn't available for this device yet. Check again later." : "Couldn't reach the update server. Try again in a few minutes.", - true - ) + userInitiated: true + }) } else { if (this.isRetryableReleaseFeedPreflightFailure(sourceError)) { // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. this.deferPendingUpdateNudgeUntilRetry() } - this.sendStatus({ state: 'idle' }) + this.sendSettledCheckStatus({ state: 'idle' }) } return } @@ -100,7 +101,7 @@ export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { if (!userInitiated) { this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) } - this.sendErrorStatus(message, userInitiated) + this.sendSettledCheckStatus({ state: 'error', message, userInitiated }) } this.pendingCheckFailureKey = failureKey diff --git a/src/main/updater/updater-check-state.ts b/src/main/updater/updater-check-state.ts index 8905029c75d..d7380c17305 100644 --- a/src/main/updater/updater-check-state.ts +++ b/src/main/updater/updater-check-state.ts @@ -1,6 +1,7 @@ import { writeMainThreadDiagnosticMarker } from '../diagnostics/main-thread-churn-probe' import { isWindowsSignatureCheckUnavailableFailure } from '../../shared/updater-windows-signature-check' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { getRetainedLinuxPackageManualInstallStatus } from '../linux-package-downloaded-status' import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' import type { UpdateCheckVariant } from './updater-types' import { UpdaterStatus } from './updater-status' @@ -229,7 +230,7 @@ export abstract class UpdaterCheckState extends UpdaterStatus { this.deferPendingUpdateNudgeUntilRetry() return } - this.sendStatus({ state: 'not-available', userInitiated }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated }) } } return @@ -239,7 +240,7 @@ export abstract class UpdaterCheckState extends UpdaterStatus { this.backgroundCheckPromotedToUserInitiated = false this.userInitiatedCheck = false this.completeSilentUpdateCheck(userInitiated) - this.sendStatus({ state: 'not-available', userInitiated }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated }) } protected handleSettledUpdateCheckPromise(attemptId: number): void { @@ -284,6 +285,21 @@ export abstract class UpdaterCheckState extends UpdaterStatus { this.sendStatus({ state: 'error', message, userInitiated }) } + /** + * Settles a check without discarding a retained manual-install card. A distro-managed host has a + * downloaded package it can still be told about, and the ordinary settle status would erase it. + */ + protected sendSettledCheckStatus(status: UpdateStatus): void { + const retainedStatus = getRetainedLinuxPackageManualInstallStatus() + if (retainedStatus) { + this.sendStatus(retainedStatus) + } else if (status.state === 'error') { + this.sendErrorStatus(status.message, status.userInitiated) + } else { + this.sendStatus(status) + } + } + protected abstract consumeMissingManifestPrereleaseFallbackResult(): { userInitiated: boolean } | null diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts index a1627d3895c..455e56e6efa 100644 --- a/src/main/updater/updater-download-install.ts +++ b/src/main/updater/updater-download-install.ts @@ -1,5 +1,7 @@ import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { isExternallyManagedLinuxInstall } from '../linux-update-package-type' +import { LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE } from '../linux-package-downloaded-status' import { QUIT_AND_INSTALL_DELAY_MS } from './updater-state' import { UpdaterRemoteStatus } from './updater-remote-status' @@ -10,22 +12,11 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || this.pendingQuitAndInstallTimer || - this.quitAndInstallInProgress || - // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so without this a second click would schedule a parallel install of the same package. - this.linuxPackageRevalidationInFlight + this.quitAndInstallInProgress ) { return } - const retriedRecovery = this.getActiveLinuxPackageRecovery() - if (retriedRecovery) { - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: retriedRecovery.packageType, - version: retriedRecovery.version - }) - } - if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { return } @@ -66,6 +57,25 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if (!version) { return } + // Why: main owns this verdict, not the card — an older renderer or a direct IPC call must not be + // able to spend a package download that this host could never install. + if (isExternallyManagedLinuxInstall()) { + recordUpdaterLifecycle('linux_package_externally_managed_download_blocked', { + version + }) + // Why: a pinned jump resolves to 'release' on Linux (no dev-channel artifact is built for it), + // so refusing without unwinding would strand isPinnedBuildActive and silently kill every + // background check for the rest of the process. A no-op on the ordinary release path. + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ + state: 'error', + message: LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE, + version, + retryable: false + }) + return + } if (this.deferHeadlessServeInstall('download', version)) { return } diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts index 4522e155865..257f8e4fa93 100644 --- a/src/main/updater/updater-install-execution.ts +++ b/src/main/updater/updater-install-execution.ts @@ -4,19 +4,15 @@ import { withUpdaterSpan } from '../observability/instrumentation' import { runWithLaunchPath } from '../startup/hydrate-shell-path' import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' -import { getLinuxRootPackageType } from '../linux-update-package-type' -import { - beginLinuxPackageInstallDiagnosticCapture, - endLinuxPackageInstallDiagnosticCapture -} from '../linux-package-install-diagnostic' -import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { getLinuxPackageType } from '../linux-update-package-type' +import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-downloaded-status' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff' import { UpdaterPackageRecovery } from './updater-package-recovery' export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { protected async performQuitAndInstall(): Promise { - if (this.quitAndInstallInProgress || this.linuxPackageRevalidationInFlight) { + if (this.quitAndInstallInProgress) { recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) return } @@ -30,20 +26,31 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { if (this.deferHeadlessServeInstall('install', pendingVersion)) { return } - // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about - // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a - // swapped or vanished package aborts instead of being installed as root. The synchronous guard - // keeps every non-Linux install on its existing timing. - if ( - getTrackedLinuxPackageArtifact() && - !(await this.proveRetainedLinuxPackage(pendingVersion)) - ) { - // Why: the renderer armed its restart before invoking, and it infers the abort from the error - // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where - // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + const linuxPackageType = getLinuxPackageType() + if (linuxPackageType === 'deb' || linuxPackageType === 'rpm') { + recordUpdaterLifecycle('linux_package_manual_install_required', { + packageType: linuxPackageType, + version: pendingVersion || null + }) + // The preload prepares renderer state before invoking; explicitly release it when main refuses. this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') return } + if (linuxPackageType === 'unusable') { + recordUpdaterLifecycle( + 'linux_package_marker_unusable', + { version: pendingVersion || null }, + { level: 'warn', message: 'Linux package marker is unusable; native install blocked' } + ) + // The preload prepares renderer state before invoking; release it when the marker is unknown. + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE, + ...(pendingVersion ? { version: pendingVersion } : {}) + }) + return + } this.quitAndInstallInProgress = true markMacQuitAndInstallInFlight() @@ -98,22 +105,14 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { } // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. this.quitAndInstallNativeInvoked = true - // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. + // Why: invoke before killAllPty/removing close listeners so a sync 'error' can recover while windows and PTYs are intact. const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' - // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. - beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) - try { - runWithLaunchPath(() => - this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) - ) - } finally { - const diagnostic = endLinuxPackageInstallDiagnosticCapture() - // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. - this.lastInstallAttemptDiagnostic = this.quitAndInstallInProgress ? diagnostic : null - } + runWithLaunchPath(() => + this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) span.addEvent('native_quit_and_install_invoked') - // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. + // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via dispatchError; skip destructive prep if it already ran. if (!this.quitAndInstallInProgress) { // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. span.fail( @@ -124,14 +123,6 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { return } - // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the - // package is installed. Commit here or a throw in the cleanup below is reported as an install - // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. - if (getLinuxRootPackageType() !== null) { - this.updateInstallCommitted = true - armUpdateInstallExitWatchdog() - } - killAllPty() span.addEvent('local_pty_kill_all') @@ -153,9 +144,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { } }) } catch (error) { - // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is - // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear - // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. + // Past commit the installer is waiting for this process to exit; keep the handoff and watchdog intact. if (this.updateInstallCommitted) { recordUpdaterLifecycle( 'post_commit_cleanup_failed', @@ -167,12 +156,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { ) return } - // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. const quitAndInstallNativeInvokedBeforeReset = this.quitAndInstallNativeInvoked - const recoveryStatus = - quitAndInstallNativeInvokedBeforeReset && !this.updateInstallCommitted - ? this.buildLinuxPackageInstallFailureStatus(error) - : null failServeUpdateHandoff('Could not invoke the native updater.') this.resetQuitForUpdateState() recordUpdaterLifecycle( @@ -183,16 +167,13 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { message: 'Could not start update install' } ) - this.sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. - // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. - message: quitAndInstallNativeInvokedBeforeReset - ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) - : 'Could not restart to install the update. Quit and reopen Orca, then try again.' - } - ) + this.sendInstallFailureStatus({ + state: 'error', + // A synchronous throw carries the same installer text the 'error' event would have. + message: quitAndInstallNativeInvokedBeforeReset + ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + : 'Could not restart to install the update. Quit and reopen Orca, then try again.' + }) } } @@ -205,10 +186,8 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { ) { return false } - const recoveryStatus = this.buildLinuxPackageInstallFailureStatus(error) failServeUpdateHandoff('The native updater rejected the install request.') this.resetQuitForUpdateState() - // Durable data carries classification only — the cause text stays on the status the user can read. recordUpdaterLifecycle( 'quit_and_install_failed_via_event', { errorType: error instanceof Error ? error.name : typeof error }, @@ -217,12 +196,10 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { message: 'Update install could not start; recovered app state' } ) - this.sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) - } - ) + this.sendInstallFailureStatus({ + state: 'error', + message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + }) return true } } diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index 048f293f01e..175362dad05 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -35,6 +35,12 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { if (this.currentStatus.state === 'downloading' || this.currentStatus.state === 'downloaded') { return this.currentStatus.version } + if ( + this.currentStatus.state === 'error' && + this.currentStatus.recovery?.kind === 'linux-package-install' + ) { + return this.currentStatus.recovery.version + } return '' } @@ -70,7 +76,6 @@ export abstract class UpdaterInstallSupport extends UpdaterCheckState { this.quittingForUpdate = false this.updateInstallCommitted = false this.quitAndInstallNativeInvoked = false - this.lastInstallAttemptDiagnostic = null disarmUpdateInstallExitWatchdog() resetMacInstallState() } diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts index b76d5c19710..5e5294f29dc 100644 --- a/src/main/updater/updater-menu-checks.ts +++ b/src/main/updater/updater-menu-checks.ts @@ -74,7 +74,7 @@ export abstract class UpdaterMenuChecks extends UpdaterScheduling { this.userInitiatedCheck = false this.finishActiveUpdateCheckAttempt() this.recordCompletedUpdateCheck() - this.sendStatus({ state: 'not-available', userInitiated: true }) + this.sendSettledCheckStatus({ state: 'not-available', userInitiated: true }) return false } return launch() diff --git a/src/main/updater/updater-package-recovery.ts b/src/main/updater/updater-package-recovery.ts index 870d601a5f3..f33b5044e48 100644 --- a/src/main/updater/updater-package-recovery.ts +++ b/src/main/updater/updater-package-recovery.ts @@ -1,22 +1,16 @@ +import { shell } from 'electron' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { getTrackedLinuxPackageArtifact, clearTrackedLinuxPackageArtifact, - revalidateLinuxPackageForInstall, resolveLinuxPackageInstallInstructions, - revealLinuxPackage, + resolveLinuxPackageRevealTarget, type LinuxPackageArtifact, type LinuxPackageRecoveryUnavailableReason } from '../linux-package-update-recovery' -import { - getLinuxPackageInstallDiagnostic, - parseLinuxPackageInstallExitCode, - redactLinuxPackageInstallText -} from '../linux-package-install-diagnostic' import type { LinuxPackageInstallInstructions, - LinuxPackageInstallRecovery, - UpdateStatus + LinuxPackageInstallRecovery } from '../../shared/update-status-types' import { UpdaterInstallSupport } from './updater-install-support' @@ -67,131 +61,47 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { ) } + /** Whether the card this action was invoked from still owns both the status and the artifact. */ + protected isCurrentLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + artifact: LinuxPackageArtifact | null + ): boolean { + return ( + this.getActiveLinuxPackageRecovery() === recovery && + getTrackedLinuxPackageArtifact() === artifact + ) + } + + protected assertCurrentLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + artifact: LinuxPackageArtifact | null + ): void { + if (!this.isCurrentLinuxPackageRecovery(recovery, artifact)) { + throw new Error('Package install recovery is no longer current.') + } + } + protected failLinuxPackageRecovery( recovery: LinuxPackageInstallRecovery, + artifact: LinuxPackageArtifact | null, reason: LinuxPackageRecoveryUnavailableReason ): never { + this.assertCurrentLinuxPackageRecovery(recovery, artifact) this.recordLinuxPackageRecoveryUnavailable(recovery, reason) const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] - // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would - // destroy the newer artifact and clobber whatever card replaced this one. - const active = this.getActiveLinuxPackageRecovery() - const stillCurrent = - active?.version === recovery.version && active?.packageType === recovery.packageType - if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { + if (RECOVERY_CLEARING_REASONS.includes(reason)) { clearTrackedLinuxPackageArtifact() - this.sendStatus({ state: 'error', message }) + this.sendStatus({ state: 'error', message, version: recovery.version }) } throw new Error(message) } - /** - * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash - * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. - */ - protected getInstallCycleSignature(): string { - const recovery = this.getActiveLinuxPackageRecovery() - if (recovery) { - return `recovery:${recovery.packageType}:${recovery.version}` - } - return this.currentStatus.state === 'downloaded' - ? `downloaded:${this.currentStatus.version}` - : `state:${this.currentStatus.state}` - } - - /** - * Re-proves the retained package before the install starts. Returns false when the install must be - * abandoned; the artifact is only re-read here, so callers still own every teardown decision. - */ - protected async proveRetainedLinuxPackage(pendingVersion: string): Promise { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return true - } - // Why: an artifact retained from another cycle says nothing about the file electron-updater is - // about to install, so proving it would block a legitimate install on an unrelated digest. - if (pendingVersion && pendingVersion !== artifact.version) { - return true - } - const recovery = this.getActiveLinuxPackageRecovery() - const cycle = this.getInstallCycleSignature() - const reason = await this.revalidateRetainedLinuxPackage(artifact) - if (!reason) { - return true - } - this.reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) - return false - } - - /** The failing reason, or null when the retained package still matches its release digest. */ - protected async revalidateRetainedLinuxPackage( - artifact: LinuxPackageArtifact - ): Promise { - this.linuxPackageRevalidationInFlight = true - try { - const verdict = await revalidateLinuxPackageForInstall(artifact) - return verdict.ok ? null : verdict.reason - } catch (error) { - recordUpdaterLifecycle( - 'linux_package_revalidation_errored', - { errorType: error instanceof Error ? error.name : typeof error }, - { level: 'warn', message: 'Could not re-verify the retained update package' } - ) - // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. - return 'read-failed' - } finally { - // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall - // early-return for the rest of the session. - this.linuxPackageRevalidationInFlight = false - } - } - - protected reportLinuxPackageRevalidationFailure({ - artifact, - recovery, - reason, - cycle - }: { - artifact: LinuxPackageArtifact - recovery: LinuxPackageInstallRecovery | null - reason: LinuxPackageRecoveryUnavailableReason - cycle: string - }): void { - recordUpdaterLifecycle( - 'linux_package_revalidation_failed', - { - action: recovery ? 'retry-automatic' : 'restart-to-install', - packageType: artifact.packageType, - version: artifact.version, - reason - }, - { level: 'warn', message: 'Retained update package failed its pre-install digest check' } - ) - // Why: a package proven bad must not stay tracked, but a download that landed during the hash - // owns the slot now and destroying it would force a needless 160 MB redownload. - const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) - if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { - clearTrackedLinuxPackageArtifact() - } - // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been - // replaced must not clobber whatever card the user is looking at now. - if (this.getInstallCycleSignature() !== cycle) { - return - } - this.sendInstallFailureStatus({ - state: 'error', - message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], - // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its - // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. - ...(recovery && !clearsArtifact ? { recovery } : {}) - }) - } - protected async getLinuxPackageInstallInstructions(): Promise { const recovery = this.getActiveLinuxPackageRecovery() if (!recovery) { throw new Error('No package install recovery is available.') } + const artifact = getTrackedLinuxPackageArtifact() recordUpdaterLifecycle('linux_package_recovery_requested', { action: 'copy-command', packageType: recovery.packageType, @@ -202,6 +112,7 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { + this.assertCurrentLinuxPackageRecovery(recovery, artifact) this.recordLinuxPackageRecoveryUnavailable(recovery, result.reason) return { ok: false, @@ -209,8 +120,9 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] } } - this.failLinuxPackageRecovery(recovery, result.reason) + this.failLinuxPackageRecovery(recovery, artifact, result.reason) } + this.assertCurrentLinuxPackageRecovery(recovery, artifact) return { ok: true, command: result.command, packageFileName: result.packageFileName } } @@ -219,56 +131,22 @@ export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { if (!recovery) { throw new Error('No package install recovery is available.') } + const artifact = getTrackedLinuxPackageArtifact() recordUpdaterLifecycle('linux_package_recovery_requested', { action: 'show-package', packageType: recovery.packageType, version: recovery.version }) - const result = await revealLinuxPackage(recovery) + const result = await resolveLinuxPackageRevealTarget(recovery) if (!result.ok) { - this.failLinuxPackageRecovery(recovery, result.reason) + this.failLinuxPackageRecovery(recovery, artifact, result.reason) } - } - - /** Builds a recoverable status when the native Linux package installer rejects a retained artifact. */ - protected buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return null - } - const pendingVersion = this.getPendingInstallVersion() - if (pendingVersion && pendingVersion !== artifact.version) { - return null - } - const diagnostic = getLinuxPackageInstallDiagnostic() ?? this.lastInstallAttemptDiagnostic - const reason = diagnostic?.reason ?? 'package-install-failed' - const exitCode = parseLinuxPackageInstallExitCode(error) - recordUpdaterLifecycle( - 'linux_package_install_failed', - { - packageType: artifact.packageType, - reason, - ...(exitCode === null ? {} : { exitCode }), - version: artifact.version, - errorType: error instanceof Error ? error.name : typeof error - }, - { level: 'warn', message: 'Linux package install failed; cached package retained' } - ) - const message = - diagnostic?.message ?? - (error instanceof Error - ? redactLinuxPackageInstallText(error.message, artifact.path) - : null) ?? - 'The system package installer did not start.' - return { - state: 'error', - message, - recovery: { - kind: 'linux-package-install', - packageType: artifact.packageType, - reason, - version: artifact.version - } + this.assertCurrentLinuxPackageRecovery(recovery, artifact) + // Why: this cache path belongs to the installed app host, not a workspace's SSH or WSL host. + try { + shell.showItemInFolder(result.path) + } catch { + this.failLinuxPackageRecovery(recovery, artifact, 'read-failed') } } } diff --git a/src/main/updater/updater-remote-status.ts b/src/main/updater/updater-remote-status.ts index 6e3db76b0c8..fc34d40b6d3 100644 --- a/src/main/updater/updater-remote-status.ts +++ b/src/main/updater/updater-remote-status.ts @@ -7,6 +7,7 @@ import type { RemoteServerUpdateSupport } from '../../shared/remote-server-update' import { hasServeUpdateSupervisor } from '../serve-update-handoff' +import { getLinuxPackageType } from '../linux-update-package-type' import { UpdaterNudge } from './updater-nudge' import type { UpdateInstallMode } from './updater-state' @@ -31,7 +32,13 @@ export abstract class UpdaterRemoteStatus extends UpdaterNudge { reason: 'updater-unavailable' } } - if (this.updateInstallMode === 'unsupported-headless-serve') { + const linuxPackageType = getLinuxPackageType() + if ( + this.updateInstallMode === 'unsupported-headless-serve' || + linuxPackageType === 'deb' || + linuxPackageType === 'rpm' || + linuxPackageType === 'unusable' + ) { return { installMode: this.updateInstallMode, automatic: false, diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts index 21354f97af3..d60444d449a 100644 --- a/src/main/updater/updater-setup.ts +++ b/src/main/updater/updater-setup.ts @@ -12,7 +12,7 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../../shared/remote-server-update' -import { getLinuxRootPackageType } from '../linux-update-package-type' +import { getLinuxPackageType } from '../linux-update-package-type' import { createUpdaterDiagnosticLogger } from '../linux-package-install-diagnostic' import { registerAutoUpdaterHandlers } from '../updater-events' import { getServeUpdateHandoffFailure } from '../serve-update-handoff' @@ -143,9 +143,9 @@ export class UpdaterSetup extends UpdaterDownloadInstall { autoUpdater.disableDifferentialDownload = false } // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. - // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. + // Only an explicit AppImage/non-root marker may opt into electron-updater's implicit quit install. autoUpdater.autoInstallOnAppQuit = - this.updateInstallMode === 'interactive' && getLinuxRootPackageType() === null + this.updateInstallMode === 'interactive' && getLinuxPackageType() === 'non-root' // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. autoUpdater.autoRunAppAfterInstall = this.updateInstallMode === 'interactive' // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts index a410c5e10b8..d15ce42520c 100644 --- a/src/main/updater/updater-state.ts +++ b/src/main/updater/updater-state.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron' import type { ElectronAutoUpdater } from '../electron-updater-loader' -import type { LinuxPackageInstallDiagnostic } from '../linux-package-install-diagnostic' import type { LocalBuildFeed } from '../local-builds/local-build-feed-server' import type { UpdateSource, UpdateStatus } from '../../shared/update-status-types' import type { ReleaseChannel } from '../../shared/release-channel' @@ -54,9 +53,6 @@ export abstract class UpdaterState { protected nudgeCheckTimer: ReturnType | null = null protected pendingQuitAndInstallTimer: ReturnType | null = null protected quitAndInstallInProgress = false - // Why: the pre-install digest re-proof streams the whole package, so a second install request can - // arrive while it runs — after the quit timer was cleared but before the handoff owns the process. - protected linuxPackageRevalidationInFlight = false protected updateInstallMode: UpdateInstallMode = 'interactive' protected lastInstallDeferralVersion = { download: null as string | null, @@ -66,8 +62,6 @@ export abstract class UpdaterState { protected updateInstallCommitted = false // Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. protected quitAndInstallNativeInvoked = false - // Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. - protected lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null protected persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null protected _getLastUpdateCheckAt: (() => number | null) | null = null protected backgroundCheckLaunchPending = false diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 128ff2e0e4e..ff7d84bd706 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -70,7 +70,7 @@ export function attachMainWindowServices( } ): void { registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) - registerRepoHandlers(mainWindow, store) + registerRepoHandlers(mainWindow, store, runtime) // Why: repo IPC mutations must also invalidate paired clients' catalogs (#11994). setRepoRemoteClientNotifier(runtime) setWorktreeCatalogRemoteClientNotifier(runtime) diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts new file mode 100644 index 00000000000..71ca0804c0e --- /dev/null +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { authorizeExternalPathMock, writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted( + () => ({ + authorizeExternalPathMock: vi.fn(), + writeFileMock: vi.fn(), + getPathMock: vi.fn(() => '/var/folders/ab/T'), + writeFileBase64Mock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock } })) +vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: getPathMock }) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: () => ({ + getTempDir: async () => '/remote/tmp', + writeFileBase64: writeFileBase64Mock + }) +})) +vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) + +import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('saveClipboardImageBufferAsTempFile', () => { + it('authorizes the local temp file so the composer can preview what it just wrote', async () => { + const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) + + expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) + // The OS temp dir is outside every allowed root, so an unauthorized path + // makes fs:readFile deny the preview read of Orca's own file. + expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath) + }) + + it('does not authorize a local path for an SSH save', async () => { + const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1]), { + connectionId: 'conn-1' + }) + + expect(savedPath.startsWith('/remote/tmp/')).toBe(true) + expect(writeFileBase64Mock).toHaveBeenCalled() + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index cadbd677650..0024c4b6d1f 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -6,6 +6,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' +import { authorizeExternalPath } from '../ipc/filesystem-auth' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null @@ -41,5 +42,8 @@ export async function saveClipboardImageBufferAsTempFile( const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) await fs.writeFile(tempPath, buffer) + // Why: the OS temp dir is outside every allowed root, so without this the + // composer's own thumbnail/preview read of the file it just wrote is denied. + authorizeExternalPath(tempPath) return tempPath } diff --git a/src/main/window/clipboard-image-thumbnail.test.ts b/src/main/window/clipboard-image-thumbnail.test.ts new file mode 100644 index 00000000000..e3cd0608972 --- /dev/null +++ b/src/main/window/clipboard-image-thumbnail.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' + +function fakeImage(overrides: Partial[0]>) { + return { + isEmpty: () => false, + getSize: () => ({ height: 10, width: 10 }), + resize: vi.fn(() => ({ toDataURL: () => 'data:image/png;base64,SMALL' })), + toDataURL: () => 'data:image/png;base64,FULL', + ...overrides + } +} + +describe('buildClipboardImageThumbnail', () => { + it('downscales to the thumbnail budget but reports the source dimensions', () => { + const image = fakeImage({ getSize: () => ({ height: 1600, width: 3200 }) }) + + expect(buildClipboardImageThumbnail(image)).toEqual({ + dataUrl: 'data:image/png;base64,SMALL', + height: 1600, + width: 3200 + }) + expect(image.resize).toHaveBeenCalledWith({ height: 160, quality: 'good', width: 320 }) + }) + + it('skips the resize for an image that already fits', () => { + const image = fakeImage({ getSize: () => ({ height: 200, width: 320 }) }) + + expect(buildClipboardImageThumbnail(image)?.dataUrl).toBe('data:image/png;base64,FULL') + expect(image.resize).not.toHaveBeenCalled() + }) + + it('reports no thumbnail for an empty clipboard so text paste falls through', () => { + expect(buildClipboardImageThumbnail(fakeImage({ isEmpty: () => true }))).toBeNull() + }) + + it('reports no thumbnail rather than throwing for an oversized image', () => { + // The save call still surfaces the real too-large error; the probe only + // decides whether a placeholder chip is worth showing. + const image = fakeImage({ getSize: () => ({ height: 100_000, width: 100_000 }) }) + + expect(buildClipboardImageThumbnail(image)).toBeNull() + expect(image.resize).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/clipboard-image-thumbnail.ts b/src/main/window/clipboard-image-thumbnail.ts new file mode 100644 index 00000000000..b2108e25f13 --- /dev/null +++ b/src/main/window/clipboard-image-thumbnail.ts @@ -0,0 +1,45 @@ +import { + assertClipboardImageDimensionsWithinLimit, + clipboardImageThumbnailSize, + type ClipboardImageDimensions, + type ClipboardImageThumbnail +} from '../../shared/clipboard-image' + +/** The slice of Electron's NativeImage this module needs, so the decision logic + * is testable without an Electron runtime. */ +export type ClipboardImageLike = { + isEmpty: () => boolean + getSize: () => ClipboardImageDimensions + resize: (options: { height: number; width: number; quality: 'good' | 'better' | 'best' }) => { + toDataURL: () => string + } + toDataURL: () => string +} + +/** + * In-memory preview of whatever image the clipboard holds. Writing the image to + * disk (or uploading it over SFTP) takes long enough that a composer with no + * feedback reads as a dropped paste, so this answers "is there an image, and + * what does it look like" without touching the filesystem. + */ +export function buildClipboardImageThumbnail( + image: ClipboardImageLike +): ClipboardImageThumbnail | null { + if (image.isEmpty()) { + return null + } + const size = image.getSize() + try { + assertClipboardImageDimensionsWithinLimit(size) + } catch { + // Oversized images still report through the save call; the probe only + // decides whether to show a placeholder, so degrade to "no preview". + return null + } + const thumbnailSize = clipboardImageThumbnailSize(size) + const thumbnail = + thumbnailSize.width === size.width && thumbnailSize.height === size.height + ? image + : image.resize({ ...thumbnailSize, quality: 'good' }) + return { dataUrl: thumbnail.toDataURL(), height: size.height, width: size.width } +} diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index ce84c68e327..ea337747421 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,6 +13,7 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, + authorizeExternalPathMock, fsAccessMock, fsLstatMock, fsMkdirMock, @@ -48,6 +49,7 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), + authorizeExternalPathMock: vi.fn(), fsAccessMock: vi.fn(), fsLstatMock: vi.fn(), fsMkdirMock: vi.fn(), @@ -90,7 +92,8 @@ vi.mock('node:fs/promises', () => ({ vi.mock('../ipc/filesystem-auth', () => ({ PATH_ACCESS_DENIED_MESSAGE: 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.', - resolveAuthorizedPath: resolveAuthorizedPathMock + resolveAuthorizedPath: resolveAuthorizedPathMock, + authorizeExternalPath: authorizeExternalPathMock })) vi.mock('node:crypto', () => ({ @@ -548,30 +551,7 @@ describe('registerClipboardHandlers', () => { expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeImage') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') - }) - - it('saves clipboard images to a local temp file when no connection is provided', async () => { - const png = Buffer.from([0, 1, 2, 3]) - const expectedPath = join( - '/tmp', - 'orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' - ) - clipboardReadImageMock.mockReturnValue({ - getSize: () => ({ height: 1, width: 1 }), - isEmpty: () => false, - toPNG: () => png - }) - - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), undefined) - ).resolves.toBe(expectedPath) - expect(fsWriteFileMock).toHaveBeenCalledWith(expectedPath, png) - expect(clipboardReadBufferMock).not.toHaveBeenCalled() - expect(fsOpenMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readImageThumbnail') }) it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => { diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 6322715ad77..9528958c25f 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -23,7 +23,8 @@ import { import { assertClipboardImageBase64LengthWithinLimit, assertClipboardImageByteLengthWithinLimit, - assertClipboardImageDimensionsWithinLimit + assertClipboardImageDimensionsWithinLimit, + type ClipboardImageThumbnail } from '../../shared/clipboard-image' import { writeFileToClipboard, @@ -37,6 +38,7 @@ import { } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' +import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' import { writeClipboardTextAndVerify } from './clipboard-text-write-verify' import { isDashboardPopoutRenderer } from './dashboard-popout-window' @@ -85,6 +87,7 @@ export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:writeImage') ipcMain.removeHandler('clipboard:writeFile') ipcMain.removeHandler('clipboard:saveImageAsTempFile') + ipcMain.removeHandler('clipboard:readImageThumbnail') void cleanupExpiredRemoteClipboardFiles() scheduleLegacyRemoteClipboardFileCleanup() @@ -100,6 +103,12 @@ export function registerClipboardHandlers(store: Store): void { return assertClipboardTextWithinLimitWithYield(clipboard.readText('selection'), options) } ) + // Why: an unanswered paste reads as a dropped paste, so the composer probes + // the clipboard in memory before the (slower) save lands. + ipcMain.handle('clipboard:readImageThumbnail', (event): ClipboardImageThumbnail | null => { + assertTrustedClipboardSender(event) + return buildClipboardImageThumbnail(clipboard.readImage()) + }) // Why: terminals need to detect clipboard images to support tools like Claude // Code that accept image input via paste. Writes the clipboard image to a // temp file and returns the path, or null if the clipboard has no image. diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts index 898f0c36f33..213f157f63b 100644 --- a/src/main/windows/windows-process-table-cim-scan.ts +++ b/src/main/windows/windows-process-table-cim-scan.ts @@ -75,8 +75,6 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[] return [] } const name = fieldAsString(row.Name) - // memoryBytes stays undefined: Win32_Process reports WorkingSetSize, but no - // caller reads it off this table and asking widens an already costly scan. return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }] }) } diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 6a565bcfc63..6dd0e25e29e 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -38,12 +38,13 @@ describe('windows process table', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) }) afterEach(() => { + vi.useRealTimers() __setWindowsProcessTreeLoaderForTests() if (platform) { Object.defineProperty(process, 'platform', platform) @@ -53,21 +54,24 @@ describe('windows process table', () => { it('maps native rows, defaulting an unreadable command line to empty', async () => { const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, { pid: 100, ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096, creationTimeMs: 1_700_000_000_000 } ]) }) - it('requests memory, command line, and creation time together', async () => { + it('requests the command line and creation time, never memory', async () => { await readWindowsProcessTableFresh() - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7) + // CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the + // addon opens a second PROCESS_VM_READ handle per process to serve it and + // nothing reads a working set off this table. + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6) + expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0) }) it('only advertises PID-safe ownership after measuring the querying process row', async () => { @@ -80,7 +84,7 @@ describe('windows process table', () => { expect(isWindowsProcessStartTimeAvailable()).toBe(true) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, CommandLine: 2 }, getAllProcesses })) expect(isWindowsProcessStartTimeAvailable()).toBe(false) @@ -89,7 +93,35 @@ describe('windows process table', () => { it('rejects a patched JS enum backed by a binary that omits creation time', async () => { await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(false) expect(isWindowsProcessStartTimeAvailable()).toBe(false) - expect(getAllProcesses).toHaveBeenCalledWith(expect.any(Function), 7) + expect(getAllProcesses).toHaveBeenCalledWith(expect.any(Function), 6) + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(false) + expect(getAllProcesses).toHaveBeenCalledTimes(1) + }) + + it('reprobes after transient failed, empty, and truncated snapshots', async () => { + vi.useFakeTimers() + getAllProcesses + .mockImplementationOnce((cb: (rows: unknown) => void) => cb(undefined)) + .mockImplementationOnce((cb: (rows: unknown) => void) => cb([])) + .mockImplementationOnce((cb: (rows: unknown) => void) => cb([{ pid: 999 }])) + .mockImplementationOnce((cb: (rows: unknown) => void) => + cb([{ ...SELF, creationTimeMs: 1_700_000_000_001 }, NATIVE[1]]) + ) + + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(false) + expect(isWindowsProcessStartTimeAvailable()).toBe(false) + // A transient failure is rate-limited, but a later renderer re-probe can + // retry the native reader in this same process. + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(false) + expect(getAllProcesses).toHaveBeenCalledTimes(attempt + 1) + await vi.advanceTimersByTimeAsync(30_000) + } + + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(true) + expect(isWindowsProcessStartTimeAvailable()).toBe(true) + expect(getAllProcesses).toHaveBeenCalledTimes(4) + vi.useRealTimers() }) it.each([0, -1, 1.5, Number.POSITIVE_INFINITY, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( @@ -110,7 +142,7 @@ describe('windows process table', () => { it('rejects a malformed creation-time enum value', async () => { __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 8 }, + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 8 }, getAllProcesses })) @@ -209,7 +241,7 @@ describe('PowerShell fallback when the native binding is absent', () => { const getAllProcesses = vi.fn() getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE)) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, CommandLine: 2 }, getAllProcesses })) await readWindowsProcessTableFresh() @@ -222,7 +254,7 @@ describe('PowerShell fallback when the native binding is absent', () => { const getAllProcesses = vi.fn() getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb([])) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, CommandLine: 2 }, getAllProcesses })) await expect(readWindowsProcessTableFresh()).rejects.toThrow(/unreadable/) @@ -267,7 +299,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -289,7 +321,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -332,7 +364,7 @@ describe('sticky wedge', () => { stuck = cb }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, CommandLine: 2 }, getAllProcesses })) @@ -351,7 +383,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn((_cb: (rows: typeof NATIVE | undefined) => void) => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, CommandLine: 2 }, getAllProcesses })) @@ -375,7 +407,7 @@ describe('sticky wedge', () => { throw new Error('addon exploded') }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -384,7 +416,7 @@ describe('sticky wedge', () => { // The recovered reader must answer, not report a wedge left by a dead timer. __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + ProcessDataFlag: { None: 0, CommandLine: 2, CreationTime: 4 }, getAllProcesses: (cb: (rows: typeof NATIVE | undefined) => void) => cb(NATIVE) })) await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) @@ -421,7 +453,7 @@ describe('resolving the native reader', () => { it('prefers the npm package where the desktop app installs it', async () => { const resolve = vi.fn((specifier: string) => { if (specifier === PACKAGE_SPECIFIER) { - return { ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, getAllProcesses } + return { ProcessDataFlag: { None: 0, CommandLine: 2 }, getAllProcesses } } throw new Error('should not reach the addon') }) @@ -441,20 +473,19 @@ describe('resolving the native reader', () => { }) const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, { pid: 100, ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096, creationTimeMs: 1_700_000_000_000 } ]) expect(isWindowsProcessTableAvailable()).toBe(true) }) - it('asks the addon for every field, including creation time', async () => { + it('asks the addon for the command line but not memory, as the package path does', async () => { const addon = addonReturning(NATIVE) __setWindowsProcessTreeRequireForTests((specifier: string) => { if (specifier === ADDON_SPECIFIER) { @@ -463,9 +494,10 @@ describe('resolving the native reader', () => { throw new Error('MODULE_NOT_FOUND') }) await readWindowsProcessTableFresh() - // Memory | CommandLine | CreationTime. The bare addon does not have the - // package enum wrapper, so this mirror is its only source of the new bit. - expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 7) + // CommandLine | CreationTime: a bare snapshot would silently drop the + // command line every agent-recognition caller matches on first, and the + // relay addon has to receive the creation-time bit explicitly. + expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 6) }) it('reaches the CIM scan when neither the package nor the addon is present', async () => { diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 261289db615..2dacc3f89ab 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -23,6 +23,10 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan' * pid+ppid+name 15.9 / 17.5 ms * +memory +commandLine 30.6 / 33.7 ms * PowerShell CIM 706 / 723 ms + * + * Those are the module's published figures for both extra fields together; the + * only flag set this module asks for is `CommandLine` (+ `CreationTime`, free), + * which sits between the two rows and has not been separately measured. */ export type WindowsProcessRow = { @@ -31,8 +35,6 @@ export type WindowsProcessRow = { name: string /** Full command line. Empty when the process denied a query handle. */ command: string - /** Working set in bytes, or undefined when not requested/queryable. */ - memoryBytes?: number /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ creationTimeMs?: number } @@ -41,7 +43,6 @@ type NativeProcessInfo = { pid: number ppid: number name: string - memory?: number commandLine?: string creationTimeMs?: number } @@ -49,7 +50,6 @@ type NativeProcessInfo = { type WindowsProcessTreeModule = { ProcessDataFlag: { None: number - Memory: number CommandLine: number CreationTime?: number } @@ -59,6 +59,24 @@ type WindowsProcessTreeModule = { ) => void } +function isWindowsProcessTreeModule(value: unknown): value is WindowsProcessTreeModule { + if (typeof value !== 'object' || value === null) { + return false + } + const candidate = value as { + ProcessDataFlag?: unknown + getAllProcesses?: unknown + } + if (typeof candidate.getAllProcesses !== 'function') { + return false + } + if (typeof candidate.ProcessDataFlag !== 'object' || candidate.ProcessDataFlag === null) { + return false + } + const flags = candidate.ProcessDataFlag as { None?: unknown; CommandLine?: unknown } + return typeof flags.None === 'number' && typeof flags.CommandLine === 'number' +} + const requireFromMain = createRequire(__filename) // Why injectable: `createRequire` bypasses the module mocker, and the two @@ -82,7 +100,10 @@ type WindowsProcessTreeAddon = { ) => void } -/** Mirrors the package's enum; the addon takes the raw bit field. */ +/** + * Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1) + * is listed for completeness and is deliberately never set — see `flags` below. + */ const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 } as const /** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */ @@ -122,8 +143,14 @@ function loadWindowsProcessTree(): WindowsProcessTreeModule | null { return cachedModule } try { - cachedModule = requireNative('@vscode/windows-process-tree') as WindowsProcessTreeModule - return cachedModule + const candidate = requireNative('@vscode/windows-process-tree') + if (isWindowsProcessTreeModule(candidate)) { + cachedModule = candidate + return cachedModule + } + // Treat an importable but malformed package like a missing binding; the + // staged relay addon may still provide a usable reader. + throw new Error('invalid windows process tree module') } catch { // Not an error here: the relay never has the package. Try the staged addon. } @@ -152,6 +179,8 @@ function loadWindowsProcessTree(): WindowsProcessTreeModule | null { * replaced self-healed in 3s because execFile owned a timeout; keep that. */ const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000 +/** Keep status reads from retrying a transient native probe on every poll. */ +const WINDOWS_PROCESS_START_TIME_PROBE_RETRY_DELAY_MS = 30_000 /** * Reads that missed their deadline and have not called back yet. @@ -163,12 +192,14 @@ let readSequence = 0 let nativeReaderEpoch = 0 let nativeProcessStartTimeCapability: boolean | undefined let nativeProcessStartTimeProbe: Promise | null = null +let nativeProcessStartTimeProbeRetryAt: number | null = null function resetNativeReaderState(): void { nativeReaderEpoch += 1 unreturnedReads.clear() nativeProcessStartTimeCapability = undefined nativeProcessStartTimeProbe = null + nativeProcessStartTimeProbeRetryAt = null } function normalizeCreationTimeMs(value: unknown): number | undefined { @@ -176,7 +207,11 @@ function normalizeCreationTimeMs(value: unknown): number | undefined { } function hasNativeCreationTimeFlag(native: WindowsProcessTreeModule): boolean { - return native.ProcessDataFlag.CreationTime === PROCESS_DATA_FLAG.CreationTime + return ( + typeof native?.ProcessDataFlag === 'object' && + native.ProcessDataFlag !== null && + native.ProcessDataFlag.CreationTime === PROCESS_DATA_FLAG.CreationTime + ) } function hasOwnProcessStartTime(processes: readonly NativeProcessInfo[]): boolean { @@ -208,14 +243,16 @@ function readNativeRows(): Promise { } const readId = ++readSequence const readerEpoch = nativeReaderEpoch - // Why always both flags: each adds an OpenProcess per process (Memory a - // GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be - // cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares - // one snapshot so a 32-wide teardown collapses into a single scan, and that - // snapshot has to satisfy every caller. Splitting the cache per field set - // would restore exactly the fan-out it exists to prevent. + // Why CommandLine but not Memory: each flag costs one OpenProcess per process + // inside the addon (process.cc), and every caller of this table matches on + // `command`, while nothing reads a working set off it -- the Resource Manager + // runs its own CIM sweep because it needs commit and CPU time in one pass, and + // `process.cc` truncates the working set into a DWORD anyway. Dropping Memory + // halves the per-snapshot handle count; the remaining flags stay in ONE flag + // set because every read shares one snapshot, so a 32-wide teardown collapses + // into a single scan. Splitting the cache per field set would restore exactly + // the fan-out it exists to prevent. const flags = - native.ProcessDataFlag.Memory | native.ProcessDataFlag.CommandLine | (hasNativeCreationTimeFlag(native) ? PROCESS_DATA_FLAG.CreationTime : 0) return new Promise((resolve, reject) => { @@ -239,9 +276,6 @@ function readNativeRows(): Promise { // that actually wedged can be holding the gate shut. unreturnedReads.delete(readId) if (!processes) { - if (readerEpoch === nativeReaderEpoch) { - nativeProcessStartTimeCapability = false - } reject(new Error('windows process table returned no snapshot')) return } @@ -253,9 +287,6 @@ function readNativeRows(): Promise { // unfalsifiably present in any honest snapshot, so this one predicate // catches empty, truncated and permission-filtered tables alike. if (!processes.some((row) => row.pid === process.pid)) { - if (readerEpoch === nativeReaderEpoch) { - nativeProcessStartTimeCapability = false - } reject(new Error('windows process table is unreadable')) return } @@ -274,7 +305,6 @@ function readNativeRows(): Promise { ppid: row.ppid, name: row.name, command: row.commandLine ?? '', - memoryBytes: row.memory, ...(creationTimeMs === undefined ? {} : { creationTimeMs }) } }) @@ -365,7 +395,14 @@ export function probeWindowsProcessStartTimeAvailability(): Promise { if (nativeProcessStartTimeProbe) { return nativeProcessStartTimeProbe } + if ( + nativeProcessStartTimeProbeRetryAt !== null && + Date.now() < nativeProcessStartTimeProbeRetryAt + ) { + return Promise.resolve(false) + } const probeEpoch = nativeReaderEpoch + nativeProcessStartTimeProbeRetryAt = Date.now() + WINDOWS_PROCESS_START_TIME_PROBE_RETRY_DELAY_MS nativeProcessStartTimeProbe = readWindowsProcessTableFresh() .then((rows) => { const supported = rows.some( @@ -378,14 +415,17 @@ export function probeWindowsProcessStartTimeAvailability(): Promise { return probeEpoch === nativeReaderEpoch ? supported : false }) .catch(() => { - if (probeEpoch === nativeReaderEpoch) { - nativeProcessStartTimeCapability = false - } + // Empty, truncated, and timed-out snapshots are transient evidence + // failures. Leave the capability unknown so the renderer's bounded + // re-probe can recover without restarting the runtime. return false }) .finally(() => { if (probeEpoch === nativeReaderEpoch) { nativeProcessStartTimeProbe = null + if (nativeProcessStartTimeCapability !== undefined) { + nativeProcessStartTimeProbeRetryAt = null + } } }) return nativeProcessStartTimeProbe diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index 26ee1c41aad..7539c6076e4 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -183,7 +183,13 @@ export function startPreparation({ await cleanupStalePreparations(preparationHostKey(repoPathKey, wslDistro), repoPath, options) await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) // Already canonical, so the add re-resolves nothing. - await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, options) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + canonicalBase, + lockReason, + options + ) })() } satisfies PreparationEntry) preparations.set(key, entry) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index c44476c9d60..1d14f526413 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -1,4 +1,5 @@ import { execFile, execFileSync } from 'node:child_process' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' type WslAvailabilityCache = | { available: true } @@ -35,6 +36,9 @@ function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: numb return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) } +// Why ENOENT stays definitive: it means wsl.exe is not on PATH. It used to also mean +// "the cwd this process inherited was deleted", which is not answer-shaped at all -- +// naming an explicit spawn directory below is what removes that source (#16463). // Why: a non-zero exit (wsl.exe ran and said no) or ENOENT (not installed) is answer-shaped, // so it earns a long window rather than the short one a timeout gets. execFileSync reports the // exit code as `status`, the execFile callback as a numeric `code`; both must count as @@ -95,7 +99,14 @@ function probeWslStatus(): Promise { execFile( 'wsl.exe', ['--status'], - { timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, windowsHide: true }, + { + timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, + windowsHide: true, + // Why explicit (#16463): inheriting a cwd the user deleted makes + // CreateProcessW fail ENOENT, which this cache reads as "WSL is not + // installed" and holds on the definitive TTL with backoff. + cwd: resolveWslInteropSpawnCwd() + }, (error: unknown) => { if (error) { reject(error) @@ -129,7 +140,10 @@ export function isWslAvailable(): boolean { try { execFileSync('wsl.exe', ['--status'], { stdio: ['pipe', 'pipe', 'pipe'], - timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS + timeout: WSL_AVAILABILITY_PROBE_TIMEOUT_MS, + // Same reason as the async twin: they share one cache, so a false ENOENT + // from either poisons both. + cwd: resolveWslInteropSpawnCwd() }) return cacheWslAvailabilityProbeResult(null, startedAtGeneration) } catch (error) { diff --git a/src/main/wsl-interop-spawn-directory.test.ts b/src/main/wsl-interop-spawn-directory.test.ts new file mode 100644 index 00000000000..310e5d3a5ca --- /dev/null +++ b/src/main/wsl-interop-spawn-directory.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + resetWslInteropSpawnDirectoryCache, + resolveWslInteropSpawnCwd +} from './wsl-interop-spawn-directory' + +// Regression coverage for #16463 ("Removing the worktree Orca was launched from +// breaks every wsl.exe spawn for the rest of the session"). The WSL command +// builders passed `cwd: undefined` meaning "the directory is inside the +// command", but CreateProcessW reads NULL as "inherit the parent's" — and the +// parent's was a `\\wsl.localhost\...` worktree Linux had just deleted. 1805 of +// 1806 git calls then failed `spawn wsl.exe ENOENT` until the app restarted. + +const createdRoots: string[] = [] + +function makeExistingDirectory(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-wsl-spawn-cwd-')) + createdRoots.push(dir) + return dir +} + +const ENV_KEYS = ['ORCA_USER_DATA_PATH', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH'] as const +const savedEnv = new Map() + +beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv.set(key, process.env[key]) + delete process.env[key] + } + resetWslInteropSpawnDirectoryCache() +}) + +afterEach(() => { + for (const key of ENV_KEYS) { + const saved = savedEnv.get(key) + if (saved === undefined) { + delete process.env[key] + } else { + process.env[key] = saved + } + } + resetWslInteropSpawnDirectoryCache() + while (createdRoots.length > 0) { + rmSync(createdRoots.pop()!, { recursive: true, force: true }) + } +}) + +describe('resolveWslInteropSpawnCwd', () => { + it('names the app-owned directory first, so no worktree can be the answer', () => { + const userData = makeExistingDirectory() + process.env.ORCA_USER_DATA_PATH = userData + process.env.USERPROFILE = makeExistingDirectory() + + expect(resolveWslInteropSpawnCwd()).toBe(userData) + }) + + it('skips a candidate that does not resolve instead of naming it', () => { + process.env.ORCA_USER_DATA_PATH = join(tmpdir(), 'orca-wsl-spawn-cwd-never-created') + const profile = makeExistingDirectory() + process.env.USERPROFILE = profile + + expect(resolveWslInteropSpawnCwd()).toBe(profile) + }) + + it('always names some directory rather than letting the spawn inherit one', () => { + // Why: inheriting is the failure mode. With no configured candidate at all + // the home directory and system root still stand between a spawn and the + // parent's cwd. + expect(resolveWslInteropSpawnCwd()).toEqual(expect.any(String)) + }) + + it('re-answers after the directory it memoized goes away mid-session', () => { + // This is the incident: the chosen directory was valid when the process + // started and was deleted underneath it hours later. A memo that is never + // re-validated reproduces the original bug one layer up. + const doomed = makeExistingDirectory() + process.env.ORCA_USER_DATA_PATH = doomed + const survivor = makeExistingDirectory() + expect(resolveWslInteropSpawnCwd()).toBe(doomed) + + rmSync(doomed, { recursive: true, force: true }) + process.env.ORCA_USER_DATA_PATH = survivor + + expect(resolveWslInteropSpawnCwd()).toBe(survivor) + }) +}) diff --git a/src/main/wsl-interop-spawn-directory.ts b/src/main/wsl-interop-spawn-directory.ts new file mode 100644 index 00000000000..daa8e08d830 --- /dev/null +++ b/src/main/wsl-interop-spawn-directory.ts @@ -0,0 +1,70 @@ +import { statSync } from 'node:fs' +import { homedir } from 'node:os' + +/** + * A Windows directory that is safe to hand `wsl.exe` as its working directory. + * + * Why this exists (#16463): the WSL command builders set `cwd: undefined`, + * meaning "the directory is already expressed inside the command" — but that is + * not what `undefined` means to `CreateProcessW`. libuv passes NULL for + * `lpCurrentDirectory`, and NULL means *inherit the parent's*. Orca launched by + * `orca-ide` from a WSL shell inherits `\\wsl.localhost\\...\` + * as its Win32 cwd; Linux can delete that directory out from under a Windows + * process across the 9P share, and from then on `CreateProcessW` fails + * `ERROR_PATH_NOT_FOUND` — surfaced by libuv as `spawn wsl.exe ENOENT`, for the + * rest of the process's life, for every repository. + * + * Naming an explicit directory removes the dependency on process-global state + * entirely, so a repaired or unrepaired `process.cwd()` cannot decide whether + * git works. It is never the cwd the command runs in: WSL invocations carry + * their Linux directory in `git -C`, a `cd` inside `bash -c`, or the `sh -c` + * wrapper `withGuestCwd` builds. + */ + +let cachedSpawnCwd: string | null = null + +function isExistingDirectory(path: string | undefined | null): path is string { + if (!path) { + return false + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +/** Test seam: forget the memoized directory so a later probe re-validates. */ +export function resetWslInteropSpawnDirectoryCache(): void { + cachedSpawnCwd = null +} + +export function resolveWslInteropSpawnCwd(): string | undefined { + // Why re-validate: the answer is only useful while it still resolves, and the + // user's profile directory can go away on a roaming/mapped-drive host. + if (isExistingDirectory(cachedSpawnCwd)) { + return cachedSpawnCwd + } + const env = process.env + // Why this order: an app-owned directory first (it outlives every worktree), + // then the user's profile, then the system root as a floor that always exists. + // A root is fine here — nothing scans this directory, it is only the value + // `CreateProcessW` receives for `lpCurrentDirectory`. + const candidates: (string | undefined)[] = [ + env.ORCA_USER_DATA_PATH, + env.USERPROFILE, + env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined, + homedir(), + env.SystemDrive ? `${env.SystemDrive}\\` : 'C:\\' + ] + for (const candidate of candidates) { + if (isExistingDirectory(candidate)) { + cachedSpawnCwd = candidate + return candidate + } + } + cachedSpawnCwd = null + // Why undefined rather than a guess: inheriting is still better than naming a + // directory we just proved does not exist. + return undefined +} diff --git a/src/main/wsl-unc-delete.test.ts b/src/main/wsl-unc-delete.test.ts index 4ca6c43312c..a902b4b6d53 100644 --- a/src/main/wsl-unc-delete.test.ts +++ b/src/main/wsl-unc-delete.test.ts @@ -50,8 +50,11 @@ describe('tryDeleteWslUncPath', () => { }) expect(execFileMock).toHaveBeenCalledTimes(1) - const [binary, spawnArgs] = execFileMock.mock.calls[0] + const [binary, spawnArgs, spawnOptions] = execFileMock.mock.calls[0] expect(binary).toBe('wsl.exe') + // Why a concrete directory (#16463): this deletes worktrees, so the cwd it + // would otherwise inherit is the very directory about to disappear. + expect(spawnOptions).toEqual(expect.objectContaining({ cwd: expect.any(String) })) expect(spawnArgs).toEqual([ '-d', 'Ubuntu', diff --git a/src/main/wsl-unc-delete.ts b/src/main/wsl-unc-delete.ts index 9cc62c9b236..b094a152beb 100644 --- a/src/main/wsl-unc-delete.ts +++ b/src/main/wsl-unc-delete.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { parseWslPath } from './wsl' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' import { containedDeleteCommand, rejectionFromWslDeleteStderr, @@ -69,7 +70,9 @@ function execFileWsl(distro: string, command: string[]): Promise { ['-d', distro, '--exec', ...command], // Why: a generous bound so deleting a large directory tree on the WSL fs // doesn't abort mid-delete, while still capping a wedged wsl.exe. - { encoding: 'utf-8', timeout: 30000 }, + // Why an explicit cwd (#16463): the target rides in argv, and this deletes + // worktrees -- so an inherited cwd is exactly the directory about to go. + { encoding: 'utf-8', timeout: 30000, cwd: resolveWslInteropSpawnCwd() }, (error, _stdout, stderr) => { if (error) { reject(wslDeleteError(error, stderr)) diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index bc3498b1145..6ef8adbbb61 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -392,6 +392,40 @@ describe('WSL availability cache', () => { }) }) + // Why this site matters more than the other wsl.exe spawns (#16463): ENOENT is + // deliberately non-retryable here, so a spawn that failed only because the + // inherited cwd had been deleted was cached as "WSL is not installed" on the + // 10-minute definitive TTL with exponential backoff. Git kept working and Orca + // reported WSL unavailable -- a worse state than the bug being fixed. Naming + // the directory is what keeps ENOENT meaning "wsl.exe is not on PATH". + it('names an explicit spawn directory on both probes, so no deleted cwd can read as ENOENT', async () => { + execFileSyncMock.mockReturnValueOnce('') + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '', '') + }) + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(true) + }) + expect(execFileSyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--status'], + expect.objectContaining({ cwd: expect.any(String) }) + ) + + // The two probes share one cache, so a false ENOENT from either poisons both. + _resetWslCachesForTests() + await withPlatformAsync('win32', async () => { + await expect(isWslAvailableAsync()).resolves.toBe(true) + }) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + ['--status'], + expect.objectContaining({ cwd: expect.any(String) }), + expect.any(Function) + ) + }) + it('shares one wsl.exe spawn between concurrent async probes', async () => { execFileMock.mockImplementation((_command, _args, _options, callback) => { setTimeout(() => callback(null, '', ''), 0) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 59e74a7f4df..579031de934 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -7,6 +7,7 @@ import { _setWslAvailabilityCacheForTests, dropStaleWslAvailabilityFailure } from './wsl-availability' +import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory' import { _resetRunningWslDistroCacheForTests, resolveRunningWslDistros @@ -76,7 +77,8 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000, - encoding: 'utf8' + encoding: 'utf8', + cwd: resolveWslInteropSpawnCwd() }) return parseWslDirectoryProbeOutput(stdout) } catch { @@ -93,7 +95,8 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise { - execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => { + const probeOpts = { timeout: 5000, cwd: resolveWslInteropSpawnCwd() } + execFile('wsl.exe', getWslDirectoryProbeArgs(info), probeOpts, (_error, stdout) => { // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. resolve(parseWslDirectoryProbeOutput(stdout)) }) @@ -181,7 +184,8 @@ export function listWslDistros(): string[] { const output = execFileSync('wsl.exe', ['--list', '--quiet'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + cwd: resolveWslInteropSpawnCwd() }) return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { @@ -283,7 +287,8 @@ export function getWslHome(distro: string): string | null { const home = execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-c', 'echo $HOME'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + cwd: resolveWslInteropSpawnCwd() }).trim() if (!home || !home.startsWith('/')) { @@ -382,7 +387,13 @@ function execFileUtf8(command: string, args: string[], env?: NodeJS.ProcessEnv): execFile( command, args, - { encoding: 'utf-8', env, timeout: 5000, windowsHide: true }, + { + encoding: 'utf-8', + env, + timeout: 5000, + windowsHide: true, + cwd: resolveWslInteropSpawnCwd() + }, (error, stdout) => { if (error) { reject(error) diff --git a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt index db6392e21f3..f0a4c4f1082 100644 --- a/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-probe-failure-swallow-allowlist.txt @@ -23,3 +23,9 @@ main/ipc/preflight-command-exec.ts main/ipc/preflight-test-harness.ts main/ipc/preflight-wsl-agent-detection.ts main/wsl.ts +# Scanned only because the filename starts with `wsl`; it answers nothing about a +# distro. The swallow is a `statSync` on a LOCAL WINDOWS directory, and only the +# positive answer is memoized -- and re-validated on every call, which is the +# point of the module (#16463). A failed stat drops to the next candidate for +# that one call and is re-asked on the next, so there is no value to pin. +main/wsl-interop-spawn-directory.ts diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index fd6da88e351..7aa6c21115d 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -1,4 +1,5 @@ import type { + AgentStatusCacheIdentity, AgentStatusClearIpcPayload, AgentStatusIpcPayload, MigrationUnsupportedPtyEntry @@ -30,6 +31,10 @@ export type AgentStatusApi = { getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ drop: (paneKey: string) => void + /** Evict a previously-cleared status only when its identity still matches the main-process cache. */ + dropPersisted: (identity: AgentStatusCacheIdentity) => void + /** Same as dropPersisted for many identities in one IPC message and one listener notification. */ + dropPersistedBatch?: (identities: readonly AgentStatusCacheIdentity[]) => void /** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a * dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */ reconcileEndedProcess: (paneKey: string) => void diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index b5ac5c8b5b2..3cc1654aaed 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { + AgentStatusCacheIdentity, AgentStatusClearIpcPayload, AgentStatusIpcPayload, MigrationUnsupportedPtyEntry @@ -66,6 +67,12 @@ export const agentStatusApi = { drop: (paneKey: string): void => { ipcRenderer.send('agentStatus:drop', paneKey) }, + dropPersisted: (identity: AgentStatusCacheIdentity): void => { + ipcRenderer.send('agentStatus:dropPersisted', identity) + }, + dropPersistedBatch: (identities: readonly AgentStatusCacheIdentity[]): void => { + ipcRenderer.send('agentStatus:dropPersistedBatch', identities) + }, reconcileEndedProcess: (paneKey: string): void => { ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) }, diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 88cb86dc32b..b2d6eed966c 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -38,6 +38,9 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Resolves when host Git can run: shell-PATH generation is published and the + * managed WSL CLI registration has reconciled. Does not wait on PTY services. */ + awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 22d46cc40d2..2a0d50e9de4 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -43,6 +43,8 @@ export const appApi = { awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + awaitGitEnvironmentStartupBarrier: (): Promise => + ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 987abab14fc..822af96714d 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -67,6 +67,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), fetch: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:fetch', args), @@ -77,6 +78,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:syncFork', args), push: (args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -84,11 +86,13 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:push', args), pull: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:pull', args), fastForward: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:fastForward', args), diff --git a/src/preload/api/platform-bridge.test.ts b/src/preload/api/platform-bridge.test.ts new file mode 100644 index 00000000000..5c8bdde34f9 --- /dev/null +++ b/src/preload/api/platform-bridge.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { platformApi } from './platform-bridge' + +const mocks = vi.hoisted(() => ({ getLinuxDisplayServer: vi.fn(() => null) })) + +vi.mock('../preload-runtime-support', () => ({ + getLinuxDisplayServer: mocks.getLinuxDisplayServer +})) + +// Electron declares getSystemVersion as required on NodeJS.Process; Node does not have it. +const mutableProcess = process as unknown as { getSystemVersion?: () => string } + +async function loadPlatformApi(): Promise { + vi.resetModules() + return (await import('./platform-bridge')).platformApi +} + +describe('platformApi.get', () => { + beforeEach(() => { + mocks.getLinuxDisplayServer.mockClear() + }) + + afterEach(() => { + delete mutableProcess.getSystemVersion + }) + + it('resolves the immutable payload once and returns the identical object', async () => { + const platformApi = await loadPlatformApi() + const getSystemVersion = vi.fn(() => '25.3.0') + mutableProcess.getSystemVersion = getSystemVersion + + const first = platformApi.get() + for (let index = 0; index < 100; index += 1) { + expect(platformApi.get()).toBe(first) + } + + expect(getSystemVersion).toHaveBeenCalledTimes(1) + expect(mocks.getLinuxDisplayServer).toHaveBeenCalledTimes(1) + expect(first.platform).toBe(process.platform) + expect(first.arch).toBe(process.arch) + expect(first.osRelease).toBe('25.3.0') + }) + + it('freezes the payload so no consumer can corrupt the shared instance', async () => { + const platformApi = await loadPlatformApi() + + expect(Object.isFrozen(platformApi.get())).toBe(true) + }) + + it('resolves nothing before the first get, keeping preload startup free', async () => { + await loadPlatformApi() + + expect(mocks.getLinuxDisplayServer).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts index 8e47a4386cf..fb1aad5de4e 100644 --- a/src/preload/api/platform-bridge.ts +++ b/src/preload/api/platform-bridge.ts @@ -1,8 +1,14 @@ import { getLinuxDisplayServer } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' -export const platformApi = { - get: () => ({ +type PlatformInfo = ReturnType + +// Why: the renderer reads this on its render cadence, and every field below is fixed +// for the process lifetime, so resolve once and hand back the same frozen payload. +let platformInfo: PlatformInfo | undefined + +function resolvePlatformInfo(): PlatformInfo { + return Object.freeze({ platform: process.platform, // Why: sandboxed preload cannot require node:os; Electron exposes the OS // version on process.getSystemVersion when available. @@ -14,4 +20,9 @@ export const platformApi = { shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', displayServer: getLinuxDisplayServer() }) +} + +export const platformApi = { + // Why: resolved lazily so preload startup keeps paying nothing for it. + get: () => (platformInfo ??= resolvePlatformInfo()) } satisfies PreloadApi['platform'] diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index e2b9ce9e665..af198ad1080 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -9,7 +9,8 @@ import type { SshTarget, SshTargetAddResult, SshTargetCreateInput, - SshTargetUpdateInput + SshTargetUpdateInput, + SshTerminateSessionsResult } from '../../shared/ssh-types' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' @@ -25,7 +26,7 @@ export type SshApi = { resolveConfigHost: (args: { alias: string }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise - terminateSessions: (args: { targetId: string }) => Promise + terminateSessions: (args: { targetId: string }) => Promise resetRelay: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise needsPassphrasePrompt: (args: { targetId: string }) => Promise diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index b0f7b89ec3d..d552fb1781d 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -10,7 +10,8 @@ import type { SshTarget, SshTargetUpdateInput, PortForwardEntry, - EnrichedDetectedPort + EnrichedDetectedPort, + SshTerminateSessionsResult } from '../../shared/ssh-types' import { admitSshConnectionStateForAuthorityReconciliation, @@ -51,7 +52,7 @@ export const sshApi = { disconnect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:disconnect', args), - terminateSessions: (args: { targetId: string }): Promise => + terminateSessions: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:terminateSessions', args), resetRelay: (args: { targetId: string }): Promise => diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index fdad19c2944..1738cb46d2a 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -10,6 +10,7 @@ import { type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' @@ -93,6 +94,8 @@ export const uiClipboardAndWindowControlsApi = { connectionId?: string | null runtimeEnvironmentId?: string | null }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), + readClipboardImageThumbnail: (): Promise => + ipcRenderer.invoke('clipboard:readImageThumbnail'), writeClipboardText: (text: string): Promise => ipcRenderer.invoke('clipboard:writeText', text), writeTerminalClipboardText: (text: string): Promise => diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index fc6aef6991b..b0fa905efa7 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -1,3 +1,4 @@ +import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { @@ -12,6 +13,7 @@ export type UiWindowApi = { connectionId?: string | null runtimeEnvironmentId?: string | null }) => Promise + readClipboardImageThumbnail: () => Promise writeClipboardText: (text: string) => Promise writeTerminalClipboardText: (text: string) => Promise writeSelectionClipboardText: (text: string) => Promise diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/relay/dispatcher-capacity-degradation.test.ts index 7bcf81674dc..2c746168ecc 100644 --- a/src/relay/dispatcher-capacity-degradation.test.ts +++ b/src/relay/dispatcher-capacity-degradation.test.ts @@ -59,6 +59,14 @@ function makeBoundedClient(highWaterMark: number): BoundedClient { return client } +// Why: the sink accepts every write but never settles it, so control-lane bytes stay retained and the +// queue fills, while the writer keeps pumping the other lanes — the shape of a peer whose socket is behind. +function makeUnsettledWriteClient(highWaterMark: number): BoundedClient { + const client = makeBoundedClient(highWaterMark) + client.options = { ...client.options, supportsWriteCallback: true } + return client +} + function decodePayload(frame: Buffer): Record { const length = frame.readUInt32BE(9) return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) @@ -464,4 +472,92 @@ describe('RelayDispatcher bounded-capacity degradation', () => { bounded.dispose() } }) + + it('answers an over-budget response with a capacity error instead of closing the connection', async () => { + const primary = makeUnsettledWriteClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + bounded.onRequest('fs.listFiles', async () => ({ paths: 'x'.repeat(700 * 1024) })) + bounded.onRequest('workspace.get', async () => ({ name: 'workspace' })) + + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 91, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + expect(primary.frames).toHaveLength(1) + + // The first reply still holds the shared control budget, so the second cannot fit under 1 MiB. + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 92, method: 'fs.listFiles' }, 2, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(bounded.isClientAttached(clientId)).toBe(true) + expect(primary.frames).toHaveLength(2) + const rejected = decodePayload(primary.frames[1]) as unknown as { + id: number + error: { code: number; message: string } + } + expect(rejected.id).toBe(92) + expect(rejected.error.code).toBe(RelayErrorCode.ResponseOverCapacity) + expect(rejected.error.message).toBe('Relay response exceeded the bounded transport capacity') + + // Every other pane and request on this connection keeps working. + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 93, method: 'workspace.get' }, 3, 0)) + await vi.advanceTimersByTimeAsync(0) + expect(decodePayload(primary.frames[2])).toMatchObject({ + id: 93, + result: { name: 'workspace' } + }) + + bounded.notify('pty.data', { paneId: 'pane-1', data: 'still-live' }) + expect(decodePayload(primary.frames[3])).toMatchObject({ method: 'pty.data' }) + expect(primary.closes).toBe(0) + } finally { + bounded.dispose() + } + }) + + it('still closes the client when a protocol-critical control frame overflows', () => { + const primary = makeUnsettledWriteClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + bounded.notifyClient(clientId, 'workspace.stale', { blob: 'x'.repeat(700 * 1024) }) + expect(primary.closes).toBe(0) + + // Replay is never re-sent, so an unnoticed drop strands the pane: overflow here stays fatal. + bounded.notify('pty.replay', { paneKey: 'tab-1:pane-1', data: 'y'.repeat(700 * 1024) }) + expect(primary.closes).toBe(1) + } finally { + bounded.dispose() + } + }) + + it('drops an unsendable response without closing when even the capacity error will not fit', async () => { + const primary = makeUnsettledWriteClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + const settlements: SinkWriteSettlement[] = [] + bounded.onRequest('workspace.get', async (_params, context) => { + context.onResponseSettled?.((result) => settlements.push(result)) + return { name: 'workspace' } + }) + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + bounded.notifyClient(clientId, `control.${index}`) + } + const framesBefore = primary.frames.length + + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 94, method: 'workspace.get' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + // Nothing goes out, but the connection lives and the caller's own request timeout settles it. + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(framesBefore) + expect(settlements).toEqual([ + { ok: false, error: new Error('Relay response was not admitted') } + ]) + } finally { + bounded.dispose() + } + }) }) diff --git a/src/relay/dispatcher-rpc-routing.ts b/src/relay/dispatcher-rpc-routing.ts index c30d222ff3d..a6e6c24190c 100644 --- a/src/relay/dispatcher-rpc-routing.ts +++ b/src/relay/dispatcher-rpc-routing.ts @@ -3,6 +3,10 @@ import { SKILL_INSTALL_RPC_ERROR_CODE, SkillInstallFailureSchema } from '../shared/skill-install-failure' +import { + TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + TerminalUnavailableCauseSchema +} from '../shared/terminal-unavailable-cause' import { RelayErrorCode, type JsonRpcNotification, @@ -135,11 +139,16 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const message = err instanceof Error ? err.message : String(err) const errorCode = (err as { code?: unknown }).code const code = typeof errorCode === 'number' ? errorCode : -32000 - const skillFailure = + // Why an allowlist keyed on the error code: error `data` is otherwise dropped, so a + // handler cannot leak internals by attaching them. Each published shape is validated + // against its own schema before it crosses. + const structured = errorCode === SKILL_INSTALL_RPC_ERROR_CODE ? SkillInstallFailureSchema.safeParse((err as { data?: unknown }).data) - : null - const data = skillFailure?.success === true ? skillFailure.data : undefined + : errorCode === TERMINAL_UNAVAILABLE_RPC_ERROR_CODE + ? TerminalUnavailableCauseSchema.safeParse((err as { data?: unknown }).data) + : null + const data = structured?.success === true ? structured.data : undefined const accepted = this.sendResponse( client, req.id, @@ -194,12 +203,17 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const frame = this.prepareFrame(msg) const lane = frame.frameBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control' - const accepted = this.enqueuePreparedFrame(client, frame, lane, onSettled) + // Why 'reject': the control lane is a shared budget, so a reply that fits the 1 MiB ceiling alone + // still overflows it under concurrent traffic. Fatal admission would close the connection — every + // pane on the host — over one listing. A response is the droppable class of control frame: it + // carries an id, so the substitute below tells that one caller, and pty.replay/notifyControl keep + // the fatal default because a silent drop there desyncs the client with nothing to retry. + const accepted = this.enqueuePreparedFrame(client, frame, lane, onSettled, 'reject') if (accepted) { return true } // Why: an oversized response must fail its own request; closing would kill every pane on the host. - // A rejected first enqueue either left onSettled untouched or closed the client, so exactly one settlement happens. + // A rejected first enqueue leaves onSettled untouched, so exactly one settlement happens. return this.enqueuePreparedFrame( client, this.prepareFrame({ @@ -218,7 +232,10 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode settlement.ok ? { ok: false, error: new Error(RESPONSE_OVER_CAPACITY_MESSAGE) } : settlement - ) + ), + // Why 'reject': if even ~150 bytes will not fit, the caller's own request timeout settles it. + // Closing to report that one request failed is the outcome this whole path exists to avoid. + 'reject' ) } diff --git a/src/relay/dispatcher-structured-error.test.ts b/src/relay/dispatcher-structured-error.test.ts index 0ba8e164216..3ce0a79f19f 100644 --- a/src/relay/dispatcher-structured-error.test.ts +++ b/src/relay/dispatcher-structured-error.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayDispatcher } from './dispatcher' import { encodeJsonRpcFrame, MessageType, type JsonRpcResponse } from './protocol' +import { + TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + type TerminalUnavailableCause +} from '../shared/terminal-unavailable-cause' function decodeResponse(frame: Buffer): JsonRpcResponse | null { if (frame[0] !== MessageType.Regular) { @@ -52,4 +56,54 @@ describe('RelayDispatcher structured errors', () => { message: 'boom' }) }) + + it('carries a terminal-unavailable cause across the wire, and rejects a malformed one', async () => { + // Why this must cross: the fault is proved on the relay at spawn time, and the only + // machinery that can repair it runs on the client. Prose cannot be acted on. + vi.useFakeTimers() + const written: Buffer[] = [] + const dispatcher = new RelayDispatcher((data) => { + written.push(Buffer.from(data)) + }) + dispatchers.push(dispatcher) + const cause: TerminalUnavailableCause = { + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for Node ABI 127, this host runs ABI 115', + repairable: true, + host: { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' + } + } + dispatcher.onRequest('pty.spawn', async () => { + throw Object.assign(new Error('Remote terminals are unavailable'), { + code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + data: cause + }) + }) + dispatcher.onRequest('pty.spawnBogus', async () => { + throw Object.assign(new Error('Remote terminals are unavailable'), { + code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + data: { status: 'blocked', repairable: true } + }) + }) + + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 8, method: 'pty.spawn' }, 1, 0)) + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 9, method: 'pty.spawnBogus' }, 2, 0)) + await vi.advanceTimersByTimeAsync(0) + + const responses = written.map(decodeResponse) + expect(responses.find((message) => message?.id === 8)?.error?.data).toEqual(cause) + // A cause that does not validate is dropped entirely; a half-read cause must never + // authorize a repair. + expect(responses.find((message) => message?.id === 9)?.error).toEqual({ + code: -32000, + message: 'Remote terminals are unavailable' + }) + }) }) diff --git a/src/relay/fs-handler-file-range.test.ts b/src/relay/fs-handler-file-range.test.ts index 3ecca25ec4c..0d601b9b9fe 100644 --- a/src/relay/fs-handler-file-range.test.ts +++ b/src/relay/fs-handler-file-range.test.ts @@ -105,10 +105,10 @@ describe('readRelayFileRange', () => { // which the writer refuses once the producer queue is busy -- so an over-wide // cap fails with ResponseOverCapacity depending on unrelated load. // - // Fitting the lane once is not enough: the control queue is a SHARED budget - // and overflowing it closes the client, so a full-cap frame has to leave room - // for a second one. Anything wider lets two pipelined tail reads -- or one - // read racing an unrelated response -- kill the connection. + // Fitting the lane once is not enough: the control queue is a SHARED budget, + // so a full-cap frame has to leave room for a second one. Anything wider lets + // two pipelined tail reads -- or one read racing an unrelated response -- + // fail as ResponseOverCapacity on load that has nothing to do with them. it('leaves control-queue headroom for a second full-cap window', async () => { const contents = Buffer.allocUnsafe(MAX_FILE_RANGE_READ_BYTES) for (let i = 0; i < contents.length; i++) { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index f8514a47191..ec11a806bb8 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -25,6 +25,7 @@ import { writeRelayFile } from './fs-path-mutation-requests' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' +import { maybeStreamRpcResponse, type GitResponseStreamRegistry } from './git-response-stream' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' import { readRelayFileRange } from './fs-handler-file-range' import { FileRangeReadRequestError } from '../shared/file-range-read' @@ -47,12 +48,19 @@ export class FsHandler { private watchRegistry: RelayFilesystemWatchRegistry private streamRegistry = new RelayStreamRegistry() private listFilesScans = new ListFilesScanCoordinator() + private readonly responseStreams: GitResponseStreamRegistry | undefined constructor( dispatcher: RelayDispatcher, _context: RelayContext, - watcherPool?: RelayWatcherProcessPool + watcherPool?: RelayWatcherProcessPool, + // Why passed in rather than owned: GitHandler registers the `git.responseAck` route every pump + // is credited through, and a client keys reassembly on `streamId` alone — see the header of + // git-response-stream.ts. Without one this handler answers plainly, which is the pre-streaming + // behavior rather than a stream nothing can credit. + responseStreams?: GitResponseStreamRegistry ) { + this.responseStreams = responseStreams this.dispatcher = dispatcher this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool) this.registerHandlers() @@ -204,7 +212,10 @@ export class FsHandler { } } - private listFiles(params: Record, context?: RequestContext): Promise { + private async listFiles( + params: Record, + context?: RequestContext + ): Promise { const rootPath = expandTilde(params.rootPath as string) const maxResults = typeof params.maxResults === 'number' && @@ -224,13 +235,21 @@ export class FsHandler { // Why #7721: full-tree scans are the relay's most expensive request; the // coordinator caps them at one per client, coalescing duplicates and // aborting a stale scan when the workspace changes or the host cancels. - return this.listFilesScans.run({ + const files = await this.listFilesScans.run({ clientId: context?.clientId ?? 0, key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]), signal: context?.signal, start: (signal) => runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery) }) + // Why: a full listing of a real monorepo serializes past the 1 MiB control lane — Orca's own + // checkout is 22.6k paths averaging 58 characters, so a 20,001-row page is ~1.2MB — and the + // legacy-response lane it demotes to is refused under unrelated producer load. Streaming makes + // size stop being a correctness question instead of picking a row or byte ceiling to refuse at. + // A client that did not opt in still gets the plain array, exactly as before. + return this.responseStreams + ? maybeStreamRpcResponse(files, params, context, this.responseStreams, this.dispatcher) + : files } private async workspaceSpaceScan(params: Record, context: RequestContext) { diff --git a/src/relay/fs-list-files-large-response.integration.test.ts b/src/relay/fs-list-files-large-response.integration.test.ts new file mode 100644 index 00000000000..27c7ee7e648 --- /dev/null +++ b/src/relay/fs-list-files-large-response.integration.test.ts @@ -0,0 +1,130 @@ +/** + * #12547: a full `fs.listFiles` reply for a real monorepo does not fit the relay's control lane. + * + * Orca's own checkout is ~22.6k tracked paths averaging 58 characters, so a 20,001-row page + * serializes to ~1.2MB — past `DISPATCHER_CONTROL_QUEUE_MAX_BYTES`, which demotes it to the + * `legacy-response` lane where an unrelated producer backlog can refuse it. Refusing at a fixed row + * or byte ceiling only moves where that shows up; streaming removes it, so these run the real + * dispatcher, the real FsHandler and the real client multiplexer over an in-memory pipe and assert + * an over-budget listing arrives intact — in both wire directions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { runListFilesScanMock } = vi.hoisted(() => ({ runListFilesScanMock: vi.fn() })) + +vi.mock('./fs-list-files-fallback-chain', () => ({ runListFilesScan: runListFilesScanMock })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' +import { RelayContext } from './context' +import { RelayDispatcher } from './dispatcher' +import { DISPATCHER_CONTROL_QUEUE_MAX_BYTES } from './dispatcher-writer-admission' +import { FsHandler } from './fs-handler' +import { GitHandler } from './git-handler' +import { GitResponseStreamRegistry } from './git-response-stream' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' + +/** Shaped like this repository: `packages//src/...`, ~58 characters. */ +function monorepoPaths(count: number): string[] { + return Array.from( + { length: count }, + (_, index) => + `packages/pkg-${String(index % 64).padStart(2, '0')}/src/renderer/components/entry-${String(index).padStart(6, '0')}.tsx` + ) +} + +describe('Integration: an over-budget fs.listFiles reply (#12547)', () => { + let mux: SshChannelMultiplexer + let dispatcher: RelayDispatcher + let fsHandler: FsHandler + let gitHandler: GitHandler + let writtenFrames: number[] + + beforeEach(() => { + runListFilesScanMock.mockReset() + writtenFrames = [] + + let relayFeed: (data: Buffer) => void + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + dispatcher = new RelayDispatcher((data: Buffer) => { + writtenFrames.push(data.length) + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + return true + }) + relayFeed = (data: Buffer) => dispatcher.feed(data) + // Why: the same single registry production wires, so `git.responseAck` — registered by + // GitHandler — credits the pump an fs.listFiles stream parks on. + const responseStreams = new GitResponseStreamRegistry() + const context = new RelayContext() + fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) + gitHandler = new GitHandler(dispatcher, context, undefined, responseStreams) + mux = new SshChannelMultiplexer(clientTransport) + }) + + afterEach(() => { + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + gitHandler.dispose() + }) + + it('delivers a page too large for the control lane, in chunks no frame has to carry', async () => { + const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS) + // Precondition, measured from the payload rather than asserted between two constants: this is + // the listing that does not fit, which is what makes the rest of the test mean anything. + expect(Buffer.byteLength(JSON.stringify(files), 'utf8')).toBeGreaterThan( + DISPATCHER_CONTROL_QUEUE_MAX_BYTES + ) + runListFilesScanMock.mockResolvedValue(files) + + const received = await requestGitStreamable(mux, 'fs.listFiles', { + rootPath: '/remote/root', + maxResults: QUICK_OPEN_LISTING_MAX_RESULTS + }) + + expect(received).toEqual(files) + expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) + + it('still answers a client that never opts into streaming, with the whole array', async () => { + const files = monorepoPaths(QUICK_OPEN_LISTING_MAX_RESULTS) + runListFilesScanMock.mockResolvedValue(files) + + // Why: an old client sends neither `__streamResponse` nor `maxResults`. It gets one plain frame + // on the legacy-response lane, as it did before this call ever learned to stream. + const received = await mux.request('fs.listFiles', { rootPath: '/remote/root' }) + + expect(received).toEqual(files) + expect(Math.max(...writtenFrames)).toBeGreaterThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) + + it('leaves a reply that fits on the plain response path', async () => { + const files = monorepoPaths(100) + runListFilesScanMock.mockResolvedValue(files) + + const received = await requestGitStreamable(mux, 'fs.listFiles', { + rootPath: '/remote/root', + maxResults: 100 + }) + + expect(received).toEqual(files) + expect(Math.max(...writtenFrames)).toBeLessThan(DISPATCHER_CONTROL_QUEUE_MAX_BYTES) + }) +}) diff --git a/src/relay/git-handler-exec-operations.ts b/src/relay/git-handler-exec-operations.ts index 510b7d0b9ee..f7d0c320697 100644 --- a/src/relay/git-handler-exec-operations.ts +++ b/src/relay/git-handler-exec-operations.ts @@ -34,6 +34,21 @@ export class GitHandlerExecOperations extends GitHandlerOperationContext { ) } + // Why: generic git.exec blocks all `git config` writes outright (CONFIG_READ_ONLY_FLAGS), + // so a deferred fork remote's provenance marker (#17828) needs its own narrow RPC that + // only ever writes this fixed key shape, mirroring renameCurrentBranch below. + async markRemoteOrcaCreated(params: Record) { + const repoPath = params.repoPath + const remoteName = params.remoteName + if (typeof repoPath !== 'string' || typeof remoteName !== 'string' || !remoteName) { + throw new Error('Invalid remote provenance marker request.') + } + if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) { + throw new Error('Invalid remote name for provenance marker.') + } + await this.git(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) + } + async renameCurrentBranch(params: Record) { return this.runWithGitReadCacheClear(async () => { const worktreePath = params.worktreePath diff --git a/src/relay/git-handler-push-target.test.ts b/src/relay/git-handler-push-target.test.ts index c040a645a16..b6fe5e96ad0 100644 --- a/src/relay/git-handler-push-target.test.ts +++ b/src/relay/git-handler-push-target.test.ts @@ -46,6 +46,17 @@ function gitForConfig(config: { } return { stdout: `${config.base ?? ''}\n`, stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: (config.remotes ?? []) + .flatMap((name) => { + const url = config.remoteUrls?.[name] ?? '' + return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`] + }) + .join('\n'), + stderr: '' + } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' } } diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts index 40765491c56..d81111d45d3 100644 --- a/src/relay/git-handler-push-target.ts +++ b/src/relay/git-handler-push-target.ts @@ -1,5 +1,6 @@ import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index' import type { GitPushTarget } from '../shared/worktree/types' type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> @@ -67,31 +68,19 @@ type ConfiguredPushRemote = { branchRemote: string | null } +// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of +// `git remote` plus a serial `git remote get-url` per remote. async function findRemoteNameForUrl( git: RelayGit, worktreePath: string, remoteUrl: string ): Promise { try { - const { stdout } = await git(['remote'], worktreePath) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await git(['remote', 'get-url', remoteName], worktreePath) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await git(['remote', '-v'], worktreePath) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } async function normalizePushRemote( @@ -120,9 +109,17 @@ async function getConfiguredPushRemote( if (!remote) { return null } + const normalizedRemote = await normalizePushRemote(git, worktreePath, remote) + // The two usually name the same URL; resolving it twice reads the remote table twice. + if (!branchRemote) { + return { remote: normalizedRemote, branchRemote: null } + } return { - remote: await normalizePushRemote(git, worktreePath, remote), - branchRemote: branchRemote ? await normalizePushRemote(git, worktreePath, branchRemote) : null + remote: normalizedRemote, + branchRemote: + branchRemote === remote + ? normalizedRemote + : await normalizePushRemote(git, worktreePath, branchRemote) } } diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts index a9f11445dd1..6462327c416 100644 --- a/src/relay/git-handler-registration.ts +++ b/src/relay/git-handler-registration.ts @@ -65,6 +65,7 @@ export function registerGitHandlers( dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => handlers.worktree.refreshLocalBaseRefForWorktreeCreate(p) ) + dispatcher.onRequest('git.markRemoteOrcaCreated', (p) => handlers.exec.markRemoteOrcaCreated(p)) dispatcher.onRequest('git.renameCurrentBranch', (p) => handlers.exec.renameCurrentBranch(p)) dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => handlers.exec.forceDeletePreservedBranch(p) diff --git a/src/relay/git-handler-worktree-list-authority.test.ts b/src/relay/git-handler-worktree-list-authority.test.ts new file mode 100644 index 00000000000..8b8a606dbba --- /dev/null +++ b/src/relay/git-handler-worktree-list-authority.test.ts @@ -0,0 +1,96 @@ +/** + * Issue #14004: a relay-side worktree-list failure must stay a failure across the relay/provider + * boundary. Converting it to `[]` reports an unreadable catalog as an authoritative empty one, and + * downstream reconciliation uses that to authorize missing-worktree teardown. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +type GitSpyTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +const WORKTREE_LIST_OUTPUT = `worktree /repo +HEAD abc123 +branch refs/heads/main +` + +/** Git <2.36 rejects `worktree list -z` with a usage error, which routes the handler to the fallback lane. */ +function unsupportedZError(): Error { + return Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) +} + +describe('relay worktree-list authority (#14004)', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + it('rejects instead of reporting an empty catalog when the fallback listing fails', async () => { + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => + args.includes('-z') + ? Promise.reject(unsupportedZError()) + : Promise.reject( + Object.assign(new Error('fatal: not a git repository'), { code: 128, stderr: '' }) + ) + ) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('not a git repository') + }) + + it('rejects a timed-out fallback listing on a host whose -z support is already known absent', async () => { + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockImplementation((args: string[]) => + args.includes('-z') + ? Promise.reject(unsupportedZError()) + : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + ) + // Prime the capability cache so the probe is not repeated; later scans go straight to the fallback. + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + + gitSpy.mockRejectedValue(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('ETIMEDOUT') + expect(gitSpy.mock.calls.at(-1)?.[0]).toEqual(['worktree', 'list', '--porcelain']) + }) + + it('republishes the catalog when a later fallback listing succeeds', async () => { + let failListing = true + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => { + if (args.includes('-z')) { + return Promise.reject(unsupportedZError()) + } + return failListing + ? Promise.reject(new Error('transient relay failure')) + : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + }) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('transient relay failure') + + failListing = false + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: '/repo' + })) as Record[] + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ path: '/repo', isMainWorktree: true }) + }) +}) diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 6689c178344..72853a342c6 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -132,19 +132,14 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { }, async () => { // Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - const normalized = await this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout) - ) - // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence(normalized) - } catch { - return [] - } + // Why no catch (#14004): swallowing to `[]` would report an unreadable catalog as an authoritative + // empty one, and callers use that to authorize missing-worktree teardown. Let the failure propagate. + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) + const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) + // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) }, isUnsupportedWorktreeListZError ) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index d8156741a3f..590b22636ba 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -73,6 +73,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.removeWorktree') expect(methods).toContain('git.worktreeIsClean') expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate') + expect(methods).toContain('git.markRemoteOrcaCreated') expect(methods).toContain('git.renameCurrentBranch') expect(methods).toContain('git.forceDeletePreservedBranch') expect(methods).toContain('git.exec') @@ -197,6 +198,37 @@ describe('GitHandler', () => { }) }) + describe('markRemoteOrcaCreated', () => { + it('writes the provenance marker via config, not the generic git.exec path', async () => { + gitInit(tmpDir) + execFileSync('git', ['remote', 'add', 'pr-contributor-orca', 'https://example.com/x.git'], { + cwd: tmpDir + }) + + await dispatcher.callRequest('git.markRemoteOrcaCreated', { + repoPath: tmpDir, + remoteName: 'pr-contributor-orca' + }) + + const value = execFileSync( + 'git', + ['config', '--get', 'remote.pr-contributor-orca.orca-created'], + { cwd: tmpDir, encoding: 'utf-8' } + ).trim() + expect(value).toBe('true') + }) + + it('rejects a remote name that is not a plain config-key segment', async () => { + gitInit(tmpDir) + await expect( + dispatcher.callRequest('git.markRemoteOrcaCreated', { + repoPath: tmpDir, + remoteName: 'bad name; rm -rf' + }) + ).rejects.toThrow('Invalid remote name for provenance marker.') + }) + }) + describe('renameCurrentBranch', () => { it('renames only the checked-out branch through the narrow RPC', async () => { gitInit(tmpDir) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 58f47da9fce..66bbd6d3cd1 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -10,8 +10,7 @@ import { createSubmodulePathsCache, type SubmodulePathsCache } from './git-handler-submodule-ops' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' +import { GitResponseStreamRegistry, maybeStreamRpcResponse } from './git-response-stream' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env' @@ -68,9 +67,6 @@ export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() private readonly gitCapabilities = new GitCapabilityCache() - // Why: use the bulk lane so large responses do not block interactive PTY echo. - private readonly responseStreams = new GitResponseStreamRegistry() - // Why: cache .gitmodules per instance to avoid SSH reads and test leakage. private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() @@ -78,7 +74,12 @@ export class GitHandler { constructor( dispatcher: RelayDispatcher, _context: RelayContext, - private readonly watcherRegistry?: GitHandlerWatcherRegistry + private readonly watcherRegistry?: GitHandlerWatcherRegistry, + // Why: use the bulk lane so large responses do not block interactive PTY echo. This handler + // registers the `git.responseAck` route below, so in production it takes the relay's single + // registry and FsHandler is handed the same one — see the header of git-response-stream.ts for + // why a second registry both collides on stream ids and stalls on credit. + private readonly responseStreams: GitResponseStreamRegistry = new GitResponseStreamRegistry() ) { this.dispatcher = dispatcher const handlers = createGitHandlerOperationSet({ @@ -132,14 +133,7 @@ export class GitHandler { params: Record, context: RequestContext | undefined ): unknown { - if (params.__streamResponse !== true || !context) { - return result - } - const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') - if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { - return result - } - return this.responseStreams.startStream(payload, this.dispatcher, context) + return maybeStreamRpcResponse(result, params, context, this.responseStreams, this.dispatcher) } private clearGitMutationReadCaches(): void { diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 3ccbd66ee8d..c9d8d2ce290 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -1,11 +1,22 @@ -// Streams large git RPC responses (diff family + exec) onto the bulk lane in -// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block -// interactive pty.data echo on the shared SSH channel. Mirrors the fs -// read-stream credit-window pattern (see fs-handler-file-read.ts) but the -// payload is an in-memory serialized string rather than a file handle. +// Streams large RPC responses onto the bulk lane in chunks instead of one +// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data +// echo on the shared SSH channel. Mirrors the fs read-stream credit-window +// pattern (see fs-handler-file-read.ts) but the payload is an in-memory +// serialized string rather than a file handle. +// +// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire +// spelling and are permanent, the way an opcode number is, so a second handler +// that needs streaming (`fs.listFiles` is the first) shares this instance rather +// than minting its own. A second registry is not an option: a client keys +// reassembly on `streamId` alone, so two would hand out the same id and +// cross-feed each other's chunks, and only the handler that registers +// `git.responseAck` can credit the ack window a pump parks on — the other's +// streams would stall at STREAM_ACK_WINDOW_CHUNKS forever. See +// `relay-runtime-services.ts` for the wiring. import type { RelayDispatcher, RequestContext } from './dispatcher' import { GIT_RESPONSE_CHUNK_SIZE, + GIT_RESPONSE_STREAM_THRESHOLD, STREAM_ACK_WINDOW_CHUNKS, STREAM_ACK_STALL_RECHECK_MS, type GitResponseStreamMarker @@ -220,3 +231,29 @@ export class GitResponseStreamRegistry { this.streams.clear() } } + +/** + * Opt-in response streaming, shared by every handler that can answer with a + * payload too large for one control-lane frame. + * + * `__streamResponse` is its own negotiation in both directions: an old client + * never sends it and gets the plain result, and an old relay ignores it and + * answers plainly, which the client detects by the sentinel marker being absent. + * So there is no new method and no capability to advertise. + */ +export function maybeStreamRpcResponse( + result: unknown, + params: Record, + context: RequestContext | undefined, + registry: GitResponseStreamRegistry, + dispatcher: RelayDispatcher +): unknown { + if (params.__streamResponse !== true || !context) { + return result + } + const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') + if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { + return result + } + return registry.startStream(payload, dispatcher, context) +} diff --git a/src/relay/node-pty-binding-survey.test.ts b/src/relay/node-pty-binding-survey.test.ts new file mode 100644 index 00000000000..b51a57fd4a8 --- /dev/null +++ b/src/relay/node-pty-binding-survey.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { afterEach, describe, expect, it } from 'vitest' +import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' +import { + collectNodePtyUnavailableDiagnosis, + readNodeGypBuildRecord, + surveyNodePtyBinding +} from './node-pty-binding-survey' +import { formatNodePtyUnavailableMessage } from './node-pty-unavailable-diagnosis' + +const HOST = { platform: process.platform, arch: process.arch } +/** What node-pty throws once its loader has replaced the real cause with its last miss. */ +const FLATTENED = + 'Failed to load native module: pty.node, checked: build/Release, build/Debug, ' + + `prebuilds/${process.platform}-${process.arch}: Error: Cannot find module './pty.node'` + +const roots: string[] = [] + +function fixture(options: { binding?: boolean; configGypi?: string } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const dir = join(root, 'node-pty') + mkdirSync(join(dir, 'lib'), { recursive: true }) + writeFileSync(join(dir, 'lib', 'index.js'), 'module.exports = {}\n') + writeFileSync(join(dir, 'lib', 'utils.js'), 'exports.loadNativeModule = () => ({})\n') + if (options.binding) { + mkdirSync(join(dir, 'build', 'Release'), { recursive: true }) + // Deliberately not a valid addon: the point is to make the dynamic loader talk. + for (const name of ['pty.node', 'conpty.node']) { + writeFileSync(join(dir, 'build', 'Release', name), 'not an addon\n') + } + } + if (options.configGypi !== undefined) { + mkdirSync(join(dir, 'build'), { recursive: true }) + writeFileSync(join(dir, 'build', 'config.gypi'), options.configGypi) + } + return dir +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }) + } +}) + +describe('surveyNodePtyBinding', () => { + it('finds the file node-pty itself would open, and lists where it looked when there is none', () => { + const withBinding = surveyNodePtyBinding(fixture({ binding: true }), HOST) + expect(withBinding?.bindingPath).toMatch(/build[/\\]Release[/\\](con)?pty\.node$/) + + const without = surveyNodePtyBinding(fixture(), HOST) + expect(without?.bindingPath).toBeNull() + expect(without?.searched).toHaveLength(3) + expect(without?.searched.join(' ')).toContain(`prebuilds/${process.platform}-${process.arch}`) + }) +}) + +describe('readNodeGypBuildRecord', () => { + it('reads what node-gyp configured for, past its leading comment lines', () => { + const dir = fixture({ + configGypi: + '# Do not edit. File was generated by node-gyp\'s "configure" step\n' + + '{ "variables": { "node_module_version": 127, "target_arch": "arm64" } }\n' + }) + expect(readNodeGypBuildRecord(dir)).toEqual({ nodeAbi: '127', arch: 'arm64' }) + }) + + it('answers nothing rather than guessing when there is no build record', () => { + expect(readNodeGypBuildRecord(fixture())).toEqual({ nodeAbi: null, arch: null }) + }) +}) + +describe('collectNodePtyUnavailableDiagnosis', () => { + it("recovers the dynamic loader's own words that node-pty threw away", async () => { + // The whole defect in one assertion: what reaches the relay is FLATTENED, which names + // no cause; the diagnosis must carry what the loader actually said about the file. + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture({ binding: true }), + error: new Error(FLATTENED) + }) + expect(diagnosis.status).toBe('blocked') + expect(diagnosis.rawError).toBeTruthy() + expect(isFlattenedNodePtyLoaderMessage(diagnosis.rawError!)).toBe(false) + expect(diagnosis.rawError).not.toBe(FLATTENED) + expect(diagnosis.rawError).toMatch(/pty\.node/) + }, 20_000) + + it("prefers node-gyp's build record over a loader message that named no fault", async () => { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture({ + binding: true, + configGypi: '{ "variables": { "node_module_version": 4242, "target_arch": "x64" } }' + }), + error: new Error(FLATTENED) + }) + // A garbage binary reads differently per platform (mach-o vs ELF), so only assert the + // build record is consulted when the loader message did not name the fault itself. + if (diagnosis.reason === 'abi_mismatch') { + expect(formatNodePtyUnavailableMessage(diagnosis)).toContain( + `built for Node ABI 4242, this host runs ABI ${process.versions.modules}` + ) + } else { + expect(['arch_mismatch', 'load_failed', 'load_crashed']).toContain(diagnosis.reason) + } + }, 20_000) + + it('reports an unlocatable install as unverifiable, not as a diagnosis', async () => { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: null, + error: new Error(FLATTENED) + }) + expect(diagnosis.status).toBe('unverifiable') + const text = formatNodePtyUnavailableMessage(diagnosis) + expect(text).toContain('could not establish why') + // It still has to be reportable: the raw error is the only thing an issue can quote. + expect(text).toContain(FLATTENED) + }) + + it('probes the host toolchain only when nothing was compiled', async () => { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture(), + error: new Error(FLATTENED) + }) + expect(diagnosis.survey?.bindingPath).toBeNull() + expect(['toolchain_missing', 'dependency_missing']).toContain(diagnosis.reason) + // Non-Linux hosts ship a node-pty prebuild, so a toolchain answer there would be noise. + expect(diagnosis.toolchain === null).toBe(process.platform !== 'linux') + + const compiled = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: fixture({ binding: true }), + error: new Error(FLATTENED) + }) + expect(compiled.toolchain).toBeNull() + }, 20_000) +}) diff --git a/src/relay/node-pty-binding-survey.ts b/src/relay/node-pty-binding-survey.ts new file mode 100644 index 00000000000..805527f1aad --- /dev/null +++ b/src/relay/node-pty-binding-survey.ts @@ -0,0 +1,215 @@ +/** + * Gather the evidence a node-pty spawn failure needs, on the host that failed. + * + * Three sources, because no single one is sufficient: + * + * 1. What is on disk where node-pty's loader looks, and what node-gyp recorded it was + * configured for (`build/config.gypi`). This answers "wrong ABI / wrong arch" even + * when the loader said nothing useful, and it is the only source available when the + * binding is absent entirely. + * 2. The dynamic loader's own words, recovered by dlopen'ing the file node-pty would + * have opened. node-pty's loader rethrows only its LAST attempt, so the real message + * is otherwise destroyed before the relay sees it. This runs in a CHILD process: a + * binding that aborts inside the loader would take the relay down with it, and a + * relay that dies is a reconnect loop rather than an error message. + * 3. The host's C/C++ toolchain, but only when nothing was compiled — "install + * build-essential" is the right answer for a compile that never ran, and noise for a + * binary that exists and is simply wrong. + * + * Every step is best-effort and failure-tolerant: whatever cannot be established is + * reported as unestablished rather than guessed (docs/reference/ssh-execution-boundary.md). + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { release } from 'node:os' +import process from 'node:process' +import { runProcess } from '../shared/child-process/run-process' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + type BuildToolchainStatus +} from '../main/ssh/build-toolchain-diagnosis' +import { detectNativeHostAbi } from '../main/orcad/native-host-abi' +import { + buildNodePtyLoadProbeScript, + readNodePtyProbeOutcome +} from '../main/orcad/node-pty-precondition' +import { + diagnoseNodePtyUnavailable, + type NodePtyBindingSurvey, + type NodePtyDiagnosisInput, + type NodePtyUnavailableDiagnosis, + type NodePtyUnavailableHost +} from './node-pty-unavailable-diagnosis' + +/** Bounded so a wedged loader delays one spawn rejection, not the relay. */ +const LOAD_PROBE_TIMEOUT_MS = 10_000 +const TOOLCHAIN_PROBE_TIMEOUT_MS = 5_000 + +/** node-pty's own search order, so the file surveyed is the file it would have opened. */ +function bindingSearchDirs(platform: NodeJS.Platform, arch: string): string[] { + return ['build/Release', 'build/Debug', `prebuilds/${platform}-${arch}`] +} + +/** Windows defers to conpty.node on builds that have ConPTY, exactly as node-pty picks it. */ +function bindingBaseName(platform: NodeJS.Platform): string { + if (platform !== 'win32') { + return 'pty' + } + return Number(release().split('.')[2]) >= 18309 ? 'conpty' : 'pty' +} + +export function surveyNodePtyBinding( + nodePtyDir: string, + host: Pick +): NodePtyBindingSurvey | null { + const name = bindingBaseName(host.platform) + const searched = bindingSearchDirs(host.platform, host.arch) + let bindingPath: string | null = null + try { + for (const dir of searched) { + for (const root of [nodePtyDir, join(nodePtyDir, 'lib')]) { + const candidate = join(root, dir, `${name}.node`) + if (existsSync(candidate)) { + bindingPath = candidate + break + } + } + if (bindingPath) { + break + } + } + } catch { + return null + } + const built = readNodeGypBuildRecord(nodePtyDir) + return { + moduleDir: nodePtyDir, + bindingPath, + searched, + builtNodeAbi: built.nodeAbi, + builtArch: built.arch + } +} + +/** + * What node-gyp configured this build for. + * + * Why this file and not the binary: `build/config.gypi` is written by `node-gyp + * configure` from the headers it downloaded, so it names the ABI and architecture the + * `.node` was compiled against without parsing ELF. It survives a build that later + * failed, which is the case where the loader has nothing to say. + */ +export function readNodeGypBuildRecord(nodePtyDir: string): { + nodeAbi: string | null + arch: string | null +} { + try { + const raw = readFileSync(join(nodePtyDir, 'build', 'config.gypi'), 'utf8') + // node-gyp prefixes the JSON with `# Do not edit…` comment lines. + const body = raw + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n') + const variables = (JSON.parse(body) as { variables?: Record }).variables + const nodeAbi = variables?.node_module_version + const arch = variables?.target_arch + return { + nodeAbi: nodeAbi === undefined || nodeAbi === null ? null : String(nodeAbi), + arch: typeof arch === 'string' && arch.length > 0 ? arch : null + } + } catch { + return { nodeAbi: null, arch: null } + } +} + +/** + * The loader's verdict on the binding, recovered out of process. + * + * Returns the pieces `diagnoseNodePtyUnavailable` reads; a probe that could not run + * answers `unverifiableBecause` rather than a cause, because it established nothing. + */ +async function probeNodePtyLoader( + nodePtyDir: string +): Promise> { + let result + try { + result = await runProcess({ + program: process.execPath, + args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)], + timeoutMs: LOAD_PROBE_TIMEOUT_MS + }) + } catch (error) { + return { + unverifiableBecause: `the node-pty load probe could not be started (${(error as Error).message})` + } + } + const outcome = readNodePtyProbeOutcome(result) + switch (outcome.kind) { + case 'loaderError': + return { loaderError: outcome.message } + case 'signalled': + return { probeSignal: outcome.signal } + case 'unanswered': + return { unverifiableBecause: outcome.detail } + // `loaded` here means the binding is fine under plain Node while the relay's own + // require failed — real, and not something the loader can explain. `noBinary` and + // `unexplained` are both better answered by the on-disk survey than by the probe. + case 'loaded': + case 'noBinary': + case 'unexplained': + return {} + } +} + +/** node-pty has no Linux prebuild, so only there does a missing toolchain explain anything. */ +async function probeRelayBuildToolchain( + platform: NodeJS.Platform +): Promise { + if (platform !== 'linux') { + return null + } + try { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', buildToolchainProbeCommand()], + timeoutMs: TOOLCHAIN_PROBE_TIMEOUT_MS + }) + return result.timedOut ? null : parseBuildToolchainProbe(result.stdout) + } catch { + return null + } +} + +function readErrorMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message + } + return typeof error === 'string' && error.length > 0 ? error : null +} + +/** + * Everything above, in the order that makes each step's cost conditional on the previous + * one's answer. Called only on the failure path, so a spawn that works pays nothing. + */ +export async function collectNodePtyUnavailableDiagnosis(options: { + nodePtyDir: string | null + error?: unknown +}): Promise { + const abi = detectNativeHostAbi() + const host: NodePtyUnavailableHost = { ...abi, nodeVersion: process.version } + const requireError = readErrorMessage(options.error) + if (!options.nodePtyDir) { + return diagnoseNodePtyUnavailable({ + host, + survey: null, + requireError, + unverifiableBecause: 'the relay could not locate its node-pty install directory' + }) + } + const survey = surveyNodePtyBinding(options.nodePtyDir, host) + const probed = survey?.bindingPath ? await probeNodePtyLoader(options.nodePtyDir) : {} + const toolchain = + survey && !survey.bindingPath ? await probeRelayBuildToolchain(host.platform) : null + return diagnoseNodePtyUnavailable({ ...probed, host, survey, requireError, toolchain }) +} diff --git a/src/relay/node-pty-unavailable-diagnosis.test.ts b/src/relay/node-pty-unavailable-diagnosis.test.ts new file mode 100644 index 00000000000..7bed6ef256e --- /dev/null +++ b/src/relay/node-pty-unavailable-diagnosis.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { + diagnoseNodePtyUnavailable, + formatNodePtyUnavailableMessage, + type NodePtyBindingSurvey, + type NodePtyDiagnosisInput, + toTerminalUnavailableCause, + type NodePtyUnavailableHost +} from './node-pty-unavailable-diagnosis' +import { parseBuildToolchainProbe } from '../main/ssh/build-toolchain-diagnosis' +import { + mayRepairFromCause, + parseTerminalUnavailableCause +} from '../shared/terminal-unavailable-cause' + +const UBUNTU_2004: NodePtyUnavailableHost = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '115', + nodeVersion: 'v20.11.0' +} + +const MODULE_DIR = '/opt/orca/relay/node_modules/node-pty' +const SEARCHED = ['build/Release', 'build/Debug', 'prebuilds/linux-x64'] + +const INSTALLED: NodePtyBindingSurvey = { + moduleDir: MODULE_DIR, + bindingPath: `${MODULE_DIR}/build/Release/pty.node`, + searched: SEARCHED, + builtNodeAbi: null, + builtArch: null +} + +const NOTHING_INSTALLED: NodePtyBindingSurvey = { ...INSTALLED, bindingPath: null } + +/** What node-pty itself throws: the real cause replaced by its LAST directory miss. */ +const FLATTENED = + 'Failed to load native module: pty.node, checked: build/Release, build/Debug, ' + + "prebuilds/linux-x64: Error: Cannot find module '../prebuilds/linux-x64//pty.node'" + +const diagnose = (overrides: Partial = {}) => + diagnoseNodePtyUnavailable({ + host: UBUNTU_2004, + survey: INSTALLED, + requireError: FLATTENED, + ...overrides + }) + +const message = (overrides: Partial = {}) => + formatNodePtyUnavailableMessage(diagnose(overrides)) + +const toolchain = (present: readonly string[]) => + parseBuildToolchainProbe([...present.map((tool) => `HAVE ${tool}`), 'PKG apt-get'].join('\n')) + +describe('diagnoseNodePtyUnavailable', () => { + it("never treats node-pty's flattened wrapper as the cause", () => { + // node-pty rethrows only its last directory miss, so acting on that text sends the + // user to install a module that is already installed. + expect( + diagnose({ survey: NOTHING_INSTALLED, toolchain: toolchain(['make', 'g++', 'python3']) }) + ).toMatchObject({ reason: 'dependency_missing' }) + expect(diagnose().reason).not.toBe('dependency_missing') + }) + + it('names the glibc the host actually has next to the one the binary needs', () => { + const verdict = diagnose({ + loaderError: + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /opt/orca/node_modules/node-pty/build/Release/pty.node)" + }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'libc_floor' }) + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain('GLIBC_2.34') + expect(text).toContain('glibc 2.31') + // The remedy is a rebuild; offering "install build-essential" here is a wrong answer. + expect(text).not.toContain('build tools') + }) + + it('names both ABI numbers from the loader message', () => { + const text = message({ + loaderError: + 'The module was compiled against a different Node.js version using NODE_MODULE_VERSION 115. ' + + 'This version of Node.js requires NODE_MODULE_VERSION 127.' + }) + expect(text).toContain('built for Node ABI 115, this host runs ABI 127') + expect(text).toContain('v20.11.0') + }) + + it("reads the ABI mismatch off node-gyp's build record when the loader said nothing", () => { + // The case the old message could only hedge about: the binding is present, node-pty + // destroyed the loader error, and the only evidence left is what node-gyp configured. + const text = message({ + survey: { ...INSTALLED, builtNodeAbi: '127' } + }) + expect(text).toContain('built for Node ABI 127, this host runs ABI 115') + }) + + it('separates an architecture mismatch from an ABI mismatch', () => { + expect(diagnose({ loaderError: 'invalid ELF header' }).reason).toBe('arch_mismatch') + expect(message({ survey: { ...INSTALLED, builtArch: 'arm64' } })).toContain( + 'built for arm64, this host runs x64' + ) + expect( + message({ + loaderError: + "dlopen(/opt/pty.node, 0x0001): tried: '/opt/pty.node' (mach-o file, but is an " + + "incompatible architecture (have 'arm64', need 'x86_64'))" + }) + ).toContain('built for arm64, this host needs x86_64') + }) + + it('separates a missing shared library from a libc floor break', () => { + // Different remedies: install a package, versus rebuild against an older toolchain. + const verdict = diagnose({ + loaderError: 'libstdc++.so.6: cannot open shared object file: No such file or directory' + }) + expect(verdict.reason).toBe('shared_library_missing') + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain('libstdc++.so.6 is not installed on this host') + expect(text).toContain('Install that library') + }) + + it('offers the build-tools remedy only when it probed the toolchain and found it missing', () => { + const missing = diagnose({ + survey: NOTHING_INSTALLED, + toolchain: toolchain(['python3']) + }) + expect(missing.reason).toBe('toolchain_missing') + const text = formatNodePtyUnavailableMessage(missing) + expect(text).toContain('make and a C++ compiler are not installed') + expect(text).toContain(`checked ${SEARCHED.join(', ')} under ${MODULE_DIR}`) + expect(text).toContain('sudo apt-get install -y build-essential python3') + + // Toolchain present and nothing compiled: the install failed for another reason, and + // "install make/g++/python3" would send the user chasing tools they already have. + const present = diagnose({ + survey: NOTHING_INSTALLED, + toolchain: toolchain(['make', 'g++', 'python3']) + }) + expect(present.reason).toBe('dependency_missing') + expect(formatNodePtyUnavailableMessage(present)).not.toContain('apt-get') + }) + + it('reports a binding that killed the probe as a crash rather than a miss', () => { + const text = message({ probeSignal: 'SIGSEGV' }) + expect(text).toContain('SIGSEGV') + expect(text).toContain('incompatible with this host rather than missing') + }) + + it('quotes the loader verbatim when nothing recognizes it', () => { + const raw = 'dlopen(/opt/pty.node): unexpected relocation kind 0x9f' + const verdict = diagnose({ loaderError: raw }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'load_failed', rawError: raw }) + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain(`Loader error: ${raw}`) + expect(text).toContain('file an issue') + }) + + it('reports a probe that never answered as unverifiable and diagnoses nothing', () => { + // docs/reference/ssh-execution-boundary.md: loss of contact is not a verdict. + const verdict = diagnose({ + unverifiableBecause: 'the node-pty load probe did not finish in time' + }) + expect(verdict.status).toBe('unverifiable') + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain('could not establish why') + expect(text).toContain('not evidence node-pty is broken') + expect(text).not.toContain('Reconnect to rebuild') + expect(text).not.toContain('apt-get') + }) + + it('marks rebuildable faults repairable and everything else not', () => { + // The relay's node-pty is compiled ON the remote, so a binding that no longer matches + // the machine is fixed by recompiling there. A missing compiler or a missing library + // is not: the rebuild would need the very thing that is absent. + const repairable = (overrides: Partial) => + toTerminalUnavailableCause(diagnose(overrides)).repairable + + expect(repairable({ survey: { ...INSTALLED, builtNodeAbi: '127' } })).toBe(true) + expect(repairable({ survey: { ...INSTALLED, builtArch: 'arm64' } })).toBe(true) + expect(repairable({ loaderError: "version `GLIBC_2.34' not found" })).toBe(true) + expect(repairable({ probeSignal: 'SIGSEGV' })).toBe(true) + expect( + repairable({ survey: NOTHING_INSTALLED, toolchain: toolchain(['make', 'g++', 'python3']) }) + ).toBe(true) + + expect(repairable({ survey: NOTHING_INSTALLED, toolchain: toolchain(['python3']) })).toBe(false) + expect(repairable({ loaderError: 'libstdc++.so.6: cannot open shared object file' })).toBe( + false + ) + // Nothing was established, so nothing may be rewritten on the host (#14830). + expect(repairable({ unverifiableBecause: 'probe timed out' })).toBe(false) + }) + + it('publishes a cause that survives its own wire schema', () => { + const cause = toTerminalUnavailableCause( + diagnose({ loaderError: "version `GLIBC_2.34' not found" }) + ) + expect(parseTerminalUnavailableCause(cause)).toEqual(cause) + expect(mayRepairFromCause(cause)).toBe(true) + expect(cause.host).toMatchObject({ arch: 'x64', nodeAbi: '115', glibcVersion: '2.31' }) + + // A peer claiming repairable on an unverifiable status must not be believed. + expect(mayRepairFromCause({ ...cause, status: 'unverifiable' })).toBe(false) + expect(parseTerminalUnavailableCause({ ...cause, host: undefined })).toBeNull() + // A reason this client has never heard of must not discard the whole cause; the + // relay may name faults added after the client shipped. + expect(parseTerminalUnavailableCause({ ...cause, reason: 'invented_later' })).not.toBeNull() + }) + + it('puts the host on every message so a bug report needs no follow-up question', () => { + for (const overrides of [ + {}, + { loaderError: 'invalid ELF header' }, + { unverifiableBecause: 'probe timed out' } + ]) { + expect(message(overrides)).toContain( + 'linux/x64, glibc 2.31, Node v20.11.0 (ABI 115), prebuild slot linux-x64-glibc' + ) + } + }) +}) diff --git a/src/relay/node-pty-unavailable-diagnosis.ts b/src/relay/node-pty-unavailable-diagnosis.ts new file mode 100644 index 00000000000..590eedcc375 --- /dev/null +++ b/src/relay/node-pty-unavailable-diagnosis.ts @@ -0,0 +1,362 @@ +/** + * Why the remote host cannot spawn terminals, in terms the user can act on and check. + * + * The relay used to answer this with one hedged paragraph — "install build tools, or + * else reconnect, or else check your Node version" — because the only thing it looked at + * was that `require('node-pty')` threw. That paragraph names three different remedies for + * four different faults and lets the user verify none of them. + * + * node-pty's own loader is why the raw cause went missing: it walks build/Release, + * build/Debug and prebuilds/-, then rethrows only the LAST error. So a + * `pty.node` the dynamic loader refused arrives as `Cannot find module '../prebuilds/…'`, + * and the GLIBC/ABI/arch sentence that actually says what is wrong is discarded before + * the relay ever sees it. Recovering it needs a separate dlopen of the file the loader + * would have opened — see node-pty-binding-survey.ts. + * + * Everything here is pure so every verdict is testable from a host that is none of the + * hosts that break. `unverifiable` is a first-class outcome: a probe that did not answer + * is not a diagnosis (docs/reference/ssh-execution-boundary.md). + */ +import { GLIBC_FLOOR, nativeSlotName, type NativeHostAbi } from '../main/orcad/native-host-abi' +import { + classifyNodePtyLoaderMessage, + isFlattenedNodePtyLoaderMessage +} from '../main/orcad/node-pty-loader-diagnosis' +import { + toolchainInstallHintLines, + type BuildToolchainStatus +} from '../main/ssh/build-toolchain-diagnosis' +import type { RuntimeTerminalUnavailableReason } from '../shared/runtime-types' +import type { TerminalUnavailableCause } from '../shared/terminal-unavailable-cause' + +/** What is actually on disk where node-pty's loader looks, and what it was built for. */ +export type NodePtyBindingSurvey = { + /** The node-pty install the relay would load from. */ + moduleDir: string + /** The compiled binding the loader would open, or null when no directory holds one. */ + bindingPath: string | null + /** Directories checked, so "nothing is installed" is a statement with evidence. */ + searched: string[] + /** `node_module_version` from node-gyp's build/config.gypi, when it is readable. */ + builtNodeAbi: string | null + /** `target_arch` from node-gyp's build/config.gypi, when it is readable. */ + builtArch: string | null +} + +export type NodePtyUnavailableHost = NativeHostAbi & { nodeVersion: string } + +export type NodePtyUnavailableDiagnosis = { + /** `blocked` — proved. `unverifiable` — nothing answered, which is not evidence. */ + status: 'blocked' | 'unverifiable' + reason: RuntimeTerminalUnavailableReason + host: NodePtyUnavailableHost + /** Short phrase naming the values found. */ + detail: string + /** The loader's own words, kept verbatim so an unclassified verdict is still reportable. */ + rawError: string | null + survey: NodePtyBindingSurvey | null + toolchain: BuildToolchainStatus | null +} + +export type NodePtyDiagnosisInput = { + /** What the recovered dlopen said, when one ran. Preferred over `requireError`. */ + loaderError?: string | null + /** What `require('node-pty')`/`pty.spawn` threw. Usually flattened by node-pty. */ + requireError?: string | null + /** A load probe that was killed rather than answering. */ + probeSignal?: NodeJS.Signals | null + /** Set when the load probe never answered at all; forces `unverifiable`. */ + unverifiableBecause?: string | null + host: NodePtyUnavailableHost + survey: NodePtyBindingSurvey | null + toolchain?: BuildToolchainStatus | null +} + +/** Reasons a loader message can establish on its own, and which nothing else outranks. */ +const LOADER_NAMED_FAULTS: ReadonlySet = new Set([ + 'abi_mismatch', + 'arch_mismatch', + 'libc_floor', + 'shared_library_missing' +]) + +export function diagnoseNodePtyUnavailable( + input: NodePtyDiagnosisInput +): NodePtyUnavailableDiagnosis { + const { host, survey } = input + const toolchain = input.toolchain ?? null + // Why the require error is only a fallback: node-pty flattens the real cause away, so + // its text is evidence of "did not load", never of why. + const usableRequireError = + input.requireError && !isFlattenedNodePtyLoaderMessage(input.requireError) + ? input.requireError + : null + // Capped because a macOS dlopen error lists every path it tried; the message quotes this + // verbatim when nothing classifies it, and a toast is not a log file. + const rawError = truncate(input.loaderError ?? usableRequireError ?? input.requireError ?? null) + const base = { host, rawError, survey, toolchain } as const + + if (input.unverifiableBecause) { + return { + ...base, + status: 'unverifiable', + reason: 'unknown', + detail: input.unverifiableBecause + } + } + // Before anything the loader said: a binary that aborts inside the loader never reaches + // a catch and often prints nothing, so the signal is the only evidence there is. + if (input.probeSignal) { + return { + ...base, + status: 'blocked', + reason: 'load_crashed', + detail: `loading the binding killed the probe with ${input.probeSignal}` + } + } + + const classifiable = input.loaderError ?? usableRequireError + const classified = classifiable ? classifyNodePtyLoaderMessage(classifiable) : null + // Only a loader message that named the fault outranks the build record. `load_failed` + // and `dependency_missing` do not: the first named nothing, and the second is what + // node-pty says about a binding it never reached. + if (classified && LOADER_NAMED_FAULTS.has(classified.reason)) { + return { ...base, status: 'blocked', ...classified } + } + + // The loader said nothing usable. The binding's own build record still can: node-gyp + // records the ABI and arch it configured for, and either differing from this runtime is + // a fault the user can check without reproducing the load. + if (survey?.bindingPath) { + if (survey.builtNodeAbi && survey.builtNodeAbi !== host.nodeAbi) { + return { + ...base, + status: 'blocked', + reason: 'abi_mismatch', + detail: `built for Node ABI ${survey.builtNodeAbi}, this host runs ABI ${host.nodeAbi}` + } + } + if (survey.builtArch && survey.builtArch !== host.arch) { + return { + ...base, + status: 'blocked', + reason: 'arch_mismatch', + detail: `built for ${survey.builtArch}, this host runs ${host.arch}` + } + } + return { + ...base, + status: 'blocked', + reason: classified?.reason ?? 'load_failed', + detail: classified?.detail ?? 'the binding is present but the loader refused it' + } + } + + if (!survey) { + return { + ...base, + status: 'unverifiable', + reason: 'unknown', + detail: "the relay could not read node-pty's install directory" + } + } + // Nothing compiled anywhere. On Linux that is either a compile that never ran for want + // of a toolchain, or an install that failed for some other reason — different remedies. + if (toolchain?.toolchainMissing) { + return { + ...base, + status: 'blocked', + reason: 'toolchain_missing', + detail: `no compiled binding exists and ${missingToolSummary(toolchain)} missing` + } + } + return { + ...base, + status: 'blocked', + reason: 'dependency_missing', + detail: 'no compiled node-pty binding exists on this host' + } +} + +const RAW_ERROR_MAX = 600 + +function truncate(message: string | null): string | null { + if (message === null || message.length <= RAW_ERROR_MAX) { + return message + } + return `${message.slice(0, RAW_ERROR_MAX)}…` +} + +function missingToolSummary(toolchain: BuildToolchainStatus): string { + const present = new Set(toolchain.present) + const missing: string[] = [] + if (!present.has('make')) { + missing.push('make') + } + if (!present.has('g++') && !present.has('c++') && !present.has('clang++')) { + missing.push('a C++ compiler') + } + if (!present.has('python3') && !present.has('python')) { + missing.push('python3') + } + if (missing.length <= 1) { + return `${missing[0] ?? 'the build tools'} is` + } + return `${missing.slice(0, -1).join(', ')} and ${missing.at(-1)} are` +} + +/** + * Faults a rebuild on the host actually fixes. + * + * The relay's node-pty is compiled ON the remote by `npm install`, so a binding that is + * absent, built for another Node ABI, built for another architecture, or linked against a + * newer libc than the host provides is all one thing: the compiled artifact no longer + * matches the machine, and recompiling here produces one that does. That is different + * from the packaged desktop app, where the binary is built elsewhere and the glibc floor + * in docs/reference/linux-glibc-compatibility.md is the binding constraint. + * + * Excluded on purpose: `toolchain_missing` (no compiler to rebuild with) and + * `shared_library_missing` (the compile would need the same absent library). + */ +const REBUILD_FIXES: ReadonlySet = new Set([ + 'abi_mismatch', + 'arch_mismatch', + 'libc_floor', + 'load_crashed', + 'dependency_missing' +]) + +/** + * The machine-readable cause, for a client that can act instead of printing. + * + * `repairable` requires a proved status AND a toolchain that is not known-missing: a + * rebuild the host cannot perform is not a repair, it is a wasted `npm install` — which + * is the shape of #14830. + */ +export function toTerminalUnavailableCause( + diagnosis: NodePtyUnavailableDiagnosis +): TerminalUnavailableCause { + const { host } = diagnosis + return { + status: diagnosis.status, + reason: diagnosis.reason, + detail: diagnosis.detail.slice(0, 400), + repairable: + diagnosis.status === 'blocked' && + REBUILD_FIXES.has(diagnosis.reason) && + diagnosis.toolchain?.toolchainMissing !== true, + host: { + platform: host.platform, + arch: host.arch, + libc: host.libc, + ...(host.glibcVersion ? { glibcVersion: host.glibcVersion } : {}), + nodeAbi: host.nodeAbi, + nodeVersion: host.nodeVersion + }, + ...(diagnosis.rawError ? { rawError: diagnosis.rawError.slice(0, 1000) } : {}) + } +} + +/** `linux/x64, glibc 2.31, Node v20.11.0 (ABI 115), prebuild slot linux-x64-glibc`. */ +function formatNodePtyHostLine(host: NodePtyUnavailableHost): string { + const libc = + host.libc === 'none' ? null : `${host.libc}${host.glibcVersion ? ` ${host.glibcVersion}` : ''}` + return [ + `${host.platform}/${host.arch}`, + libc, + `Node ${host.nodeVersion} (ABI ${host.nodeAbi})`, + `prebuild slot ${nativeSlotName(host)}` + ] + .filter((part): part is string => part !== null) + .join(', ') +} + +/** + * One remedy per fault, each naming a value the user can go and check. + * + * `unverifiable` deliberately prescribes nothing: the relay proved only that it could not + * establish a cause, and dressing that up as a diagnosis is the bug this replaces. + */ +export function formatNodePtyUnavailableMessage(diagnosis: NodePtyUnavailableDiagnosis): string { + const { host } = diagnosis + // Unverifiable deliberately prescribes nothing beyond a retry: nothing was established, + // and dressing that up as a diagnosis is the bug this replaces. + const opening = + diagnosis.status === 'unverifiable' + ? `Remote terminals are unavailable, and the relay could not establish why: ${diagnosis.detail}. ` + + `That is not evidence node-pty is broken — reconnect to retry.` + : `Remote terminals are unavailable: ${remedyFor(diagnosis)}` + const lines = [opening, `Host: ${formatNodePtyHostLine(host)}.`] + // Quoted only where nothing else named the fault: elsewhere the remedy already carries + // the numbers, and a dlopen dump would bury them. + const quoteRaw = + diagnosis.status === 'unverifiable' || + diagnosis.reason === 'load_failed' || + diagnosis.reason === 'unknown' + if (diagnosis.rawError && quoteRaw) { + lines.push(`Loader error: ${diagnosis.rawError}`) + } + return lines.join('\n') +} + +function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { + const { host, survey, toolchain } = diagnosis + switch (diagnosis.reason) { + case 'toolchain_missing': + return ( + `node-pty ships no prebuilt binary for Linux and this host has no compiled one ` + + `(${searchedPhrase(survey)}), because ${toolchain ? missingToolSummary(toolchain) : 'the build tools are'} not installed. ` + + `Install them on the remote host, then reconnect:\n` + + `${(toolchain ? toolchainInstallHintLines(toolchain) : []).join('\n')}` + ) + case 'dependency_missing': + return ( + `node-pty has no compiled binary on this host (${searchedPhrase(survey)}). ` + + `The C/C++ build tools needed to compile it are present, so reconnect to reinstall ` + + `the relay's native modules.` + ) + case 'abi_mismatch': + return ( + `the installed node-pty binding was built for a different Node ABI than the remote's ` + + `Node — ${diagnosis.detail}. Reconnect to rebuild node-pty against ${host.nodeVersion}, ` + + `or run the relay on the Node version the binding was built for.` + ) + case 'arch_mismatch': + return ( + `the installed node-pty binding does not match this host's CPU architecture — ` + + `${diagnosis.detail}. Reconnect to rebuild node-pty on the remote host; a binding ` + + `copied from a machine of another architecture can never load here.` + ) + case 'libc_floor': + return ( + `${diagnosis.detail}, which this host's C library does not provide ` + + `(${host.glibcVersion ? `glibc ${host.glibcVersion}` : 'this host reports no glibc version'}). ` + + `The binding was compiled on a newer system than this one. Reconnect to rebuild ` + + `node-pty here; Orca's own Linux floor is glibc ${GLIBC_FLOOR}.` + ) + case 'shared_library_missing': + return ( + `node-pty's native binding cannot be opened because ${diagnosis.detail}. ` + + `Install that library on the remote host, then reconnect.` + ) + case 'load_crashed': + return ( + `${diagnosis.detail}, which means the binding is incompatible with this host rather ` + + `than missing. Reconnect to rebuild the relay's native modules.` + ) + case 'load_failed': + case 'spawn_helper_missing': + case 'unknown': + return ( + `this host refused to load node-pty's native binding and the cause was not recognized. ` + + `Reconnect to rebuild the relay's native modules; if that does not help, please file an ` + + `issue quoting the loader error below.` + ) + } +} + +function searchedPhrase(survey: NodePtyBindingSurvey | null): string { + return survey && survey.searched.length > 0 + ? `checked ${survey.searched.join(', ')} under ${survey.moduleDir}` + : 'nothing was found where node-pty looks' +} diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 2df29b95420..728f883d3b1 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -32,7 +32,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe })) -import { MAX_RELAY_PTY_SESSIONS, PtyHandler, formatNodePtyUnavailableMessage } from './pty-handler' +import { MAX_RELAY_PTY_SESSIONS, PtyHandler } from './pty-handler' import type { RelayDispatcher } from './dispatcher' import { beginPtyHandlerTest, @@ -222,24 +222,6 @@ describe('PtyHandler', () => { expect(mockPtySpawn).toHaveBeenCalledOnce() }) - it('hedges both causes on Linux and offers the build-tools remedy nowhere else', () => { - const linux = formatNodePtyUnavailableMessage('linux') - expect(linux).toContain('Remote terminals are unavailable') - // Conditional, not asserted: a host with build-essential can still hit an ABI/Node-version flip. - expect(linux).toMatch(/If it is missing the C\/C\+\+ build tools/) - expect(linux).toContain('python3') - expect(linux).toContain('version and architecture match the installed binding') - - // Windows/macOS ship node-pty prebuilds, so "install make/g++/python3" sends the user chasing nothing. - for (const platform of ['win32', 'darwin'] as const) { - const message = formatNodePtyUnavailableMessage(platform) - expect(message).toContain('Remote terminals are unavailable') - expect(message).not.toContain('build tools') - expect(message).not.toContain('python3') - expect(message).toMatch(/reconnect/i) - } - }) - it('normalizes a missing native binding as degraded node-pty availability', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error( @@ -253,6 +235,30 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(0) }) + it('keeps the load error it was handed instead of replacing it with guesses', async () => { + // #17830: the user got three remedies for four possible faults and could verify none. + // The relay must carry what it was actually told, and must not prescribe a toolchain + // install it never probed for. + const thrown = + 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' + mockPtySpawn.mockImplementationOnce(() => { + throw new Error(thrown) + }) + + const message = await dispatcher.callRequest('pty.spawn', {}).then( + () => '', + (error: Error) => error.message + ) + + expect(message).toContain(thrown) + expect(message).not.toContain('install make, a C++ compiler, and python3') + // Nothing here established a cause — the relay's node-pty directory is not on disk in + // this harness — so per docs/reference/ssh-execution-boundary.md it must say so rather + // than pick a diagnosis. Every message still names the host, for the bug report. + expect(message).toContain('could not establish why') + expect(message).toMatch(/Host: linux\/\w+, .*Node v[\d.]+ \(ABI \d+\)/) + }) + it('preserves unrelated node-pty spawn failures', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error('File not found: missing-shell.exe') diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 82f0b19b9ff..f8bd2351cf2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -102,23 +102,16 @@ import { injectRelayFishHistoryEnv, injectRelayHistoryEnv } from './terminal-history' - -// Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap -// there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never -// why — a skipped compile and a later Node/ABI flip look identical here — so Linux hedges both causes. -export function formatNodePtyUnavailableMessage(platform: NodeJS.Platform): string { - const remedy = - platform === 'linux' - ? "node-pty's native binding is not loadable on this host. If it is missing the C/C++ build tools needed to compile node-pty, install make, a C++ compiler, and python3 on the remote host, then reconnect. Otherwise reconnect to reinstall the relay's native modules, and check that the remote Node.js version and architecture match the installed binding." - : "node-pty's native binding failed to load on this host. Reconnect to reinstall the relay's native modules; if it persists, check that the remote Node.js version and architecture match the installed binding." - return `Remote terminals are unavailable: ${remedy}` -} +import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' +import { collectNodePtyUnavailableDiagnosis } from './node-pty-binding-survey' +import { + formatNodePtyUnavailableMessage, + toTerminalUnavailableCause +} from './node-pty-unavailable-diagnosis' +import { TERMINAL_UNAVAILABLE_RPC_ERROR_CODE } from '../shared/terminal-unavailable-cause' function isMissingNodePtyNativeBinding(error: unknown): boolean { - return ( - error instanceof Error && - /Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message) - ) + return error instanceof Error && isFlattenedNodePtyLoaderMessage(error.message) } function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | undefined { @@ -474,6 +467,8 @@ export class PtyHandler { private ptyModule: typeof NodePty | null = null private ptyModuleLoadPromise: Promise | null = null private reloadPtyModuleFromDisk = false + /** The last thing `require('node-pty')` threw, kept because it is the only cause anyone has. */ + private lastPtyLoadError: unknown = null // Why: single optional slot is intentional — callers compose externally; a throw is swallowed so it can't block cleanup. private exitListener: PtyExitListener | null = null private surfaceRetiredListener: PtySurfaceRetiredListener | null = null @@ -547,27 +542,56 @@ export class PtyHandler { try { this.ptyModule = await import('node-pty') return this.ptyModule - } catch { + } catch (error) { + // Why keep it: this is the only place the load error exists. Discarding it here is + // what left the relay able to say "unavailable" and never why. + this.lastPtyLoadError = error this.reloadPtyModuleFromDisk = true } } // Why: tie module resolution to the deployed bundle dir, not cwd. - const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js') + const moduleEntry = join(this.relayNodePtyDir(), 'lib', 'index.js') if (!existsSync(moduleEntry)) { + this.lastPtyLoadError = this.lastPtyLoadError ?? new Error(`no node-pty at ${moduleEntry}`) return null } try { this.ptyModule = require(moduleEntry) as typeof NodePty return this.ptyModule - } catch { + } catch (error) { + this.lastPtyLoadError = error return null } } + /** Where the relay's own node-pty lives — the deployed bundle dir, never cwd. */ + private relayNodePtyDir(): string { + return join(__dirname, 'node_modules', 'node-pty') + } + + /** + * The rejection for a spawn that cannot happen: prose for a human, and the structured + * cause for a client that can repair the host instead of printing a paragraph. + * + * Runs the survey and out-of-process load probe only here, on the failure path, so a + * healthy relay never pays for them. + */ + private async nodePtyUnavailableError(spawnError?: unknown): Promise { + const nodePtyDir = this.relayNodePtyDir() + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: existsSync(nodePtyDir) ? nodePtyDir : null, + error: spawnError ?? this.lastPtyLoadError + }) + return Object.assign(new Error(formatNodePtyUnavailableMessage(diagnosis)), { + code: TERMINAL_UNAVAILABLE_RPC_ERROR_CODE, + data: toTerminalUnavailableCause(diagnosis) + }) + } + private invalidatePtyModuleAfterBindingFailure(): void { this.ptyModule = null this.reloadPtyModuleFromDisk = true - const moduleRoot = join(__dirname, 'node_modules', 'node-pty') + const moduleRoot = this.relayNodePtyDir() for (const cachedPath of Object.keys(require.cache)) { if (isPathInsideOrEqual(moduleRoot, cachedPath)) { delete require.cache[cachedPath] @@ -1718,7 +1742,7 @@ export class PtyHandler { }> { const pty = await this.loadPty() if (!pty) { - throw new Error(formatNodePtyUnavailableMessage(process.platform)) + throw await this.nodePtyUnavailableError() } const cols = (params.cols as number) || 80 @@ -1831,7 +1855,7 @@ export class PtyHandler { // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. if (isMissingNodePtyNativeBinding(error)) { this.invalidatePtyModuleAfterBindingFailure() - throw new Error(formatNodePtyUnavailableMessage(process.platform)) + throw await this.nodePtyUnavailableError(error) } throw error } diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index c806faa7978..6bfeab1a56a 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -299,10 +299,34 @@ describe('resolveDefaultCwd', () => { }) describe('getForegroundProcessName', () => { - it('returns clear non-wrapper foregrounds without process-table enrichment', async () => { - await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim') + it('keeps a non-agent foreground name when the process table shows no agent', async () => { + await withProcessPlatform('darwin', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { stdout: ['100 99 Ss zsh -l', '101 100 S+ vim notes.md'].join('\n') } + } + return new Error('unexpected command') + }) - expect(execFileMock).not.toHaveBeenCalled() + await expect(getForegroundProcessName(100, 'vim')).resolves.toBe('vim') + }) + }) + + it('resolves a macOS p_comm basename to the agent that owns the foreground', async () => { + // Why: node-pty reports the native Claude binary as its version directory (`2.1.258`); + // answering with that name downgrades agent prompts to unframed chunks (STA-4577). + await withProcessPlatform('darwin', async () => { + mockExecFile((_command, args) => { + if (args[0] === '-axo') { + return { + stdout: ['100 99 Ss zsh -l', '101 100 S+ claude --model haiku'].join('\n') + } + } + return new Error('unexpected command') + }) + + await expect(getForegroundProcessName(100, '2.1.258')).resolves.toBe('claude') + }) }) it('recognizes SSH relay node-wrapped agents from descendant command lines', async () => { diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index accccb9e702..e89ac60a7ed 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -6,22 +6,20 @@ import { promisify } from 'node:util' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, - recognizeAgentProcess, - recognizeAgentProcessFromCommandLine + recognizeAgentProcess } from '../shared/agent-process-recognition' import { getFirstCommandToken } from '../shared/command-token-scanner' import { - getProcessTableIndex, getProcessTableSnapshot, - scoreForegroundCandidateRow, type ProcessTableIndex, type ProcessTableRow } from '../shared/process-table-snapshot' +import { getProcessTableIndex } from '../shared/process-table-index' +import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection' import { resolveOuterWrapperForegroundProcess, shouldInspectOuterWrapperForegroundProcess } from '../shared/foreground-wrapper-agent' -import { isShellProcess } from '../shared/shell-process-detection' import { resolveWindowsAgentForegroundProcess, shouldInspectWindowsAgentForeground @@ -240,9 +238,7 @@ function getForegroundProcessNameFromProcessTable( // snapshot no longer each rebuild the parent/child map over every row. const index = getProcessTableIndex(rows) const root = index.byPid.get(pid) - const candidates = collectDescendants(index, pid).sort( - (a, b) => scoreForegroundCandidateRow(b) - scoreForegroundCandidateRow(a) - ) + const candidates = collectDescendants(index, pid) // Why: SSH relays do not have the daemon's async wrapper cache. Inspect the // remote process tree so node/python agent entrypoints become real agents. const foregroundIsKnown = @@ -264,13 +260,12 @@ function getForegroundProcessNameFromProcessTable( ) { return null } - for (const candidate of inspectionCandidates) { - const recognized = recognizeAgentProcessFromCommandLine(candidate.command) - if (recognized) { - // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. - return resolveOuterWrapperForegroundProcess(recognized, candidate, candidates) - } + const ancestryCandidates = root ? [{ ...root, depth: 0 }, ...candidates] : candidates + const selected = selectForegroundProcessCandidate(inspectionCandidates, ancestryCandidates) + if (selected) { + // Why: return the outer wrapper (omp) rather than a deeper recognized helper + // in the same process lineage. + return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) } return null } @@ -309,10 +304,11 @@ export async function getForegroundProcessName( (await resolveWindowsAgentForegroundProcess(pid, fallbackProcess, {})) ?? fallbackProcess ) } - if (!isShellProcess(fallbackProcess) && !isAgentForegroundWrapperProcess(fallbackProcess)) { - return fallbackProcess - } } + // Why: an unrecognized name is not proof of a non-agent foreground -- macOS p_comm truncates + // to the executable basename, which for the native Claude install is its version directory + // (`2.1.258`). The TTL-cached table read resolves the real command line; a foreground that + // is genuinely not an agent still answers with its own name below. const recognized = await getRecognizedForegroundDescendant(pid, fallbackProcess) if (recognized) { return recognized diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts index bad396b5b68..16b6e81c61b 100644 --- a/src/relay/relay-pty-source-publication.ts +++ b/src/relay/relay-pty-source-publication.ts @@ -65,20 +65,24 @@ export class RelayPtySourcePublication { context: RequestContext | undefined, recovery?: PtySourceRecoveryRequest ): false | 'opened' | 'rotated' | 'existing' | PtySourceRecoveryResult { + let current = this.deliveries.get(id) + // A superseded request can find the delivery its own replacement opened: releasing that fence + // resumes a send the replacement is still rotating, and cancelling it blanks the pane that owns + // it. So every bail-out below acts only on a record this caller still owns. + const owned = current?.clientId === context?.clientId ? current : undefined if (!context?.onResponseSettled) { - this.sender.releaseRotationFence(this.deliveries.get(id)) + this.sender.releaseRotationFence(owned) return false } const mode = this.session.deliveryMode(context.clientId) - let current = this.deliveries.get(id) if (mode === 'unadmitted' || mode === 'subscriber') { - this.sender.releaseRotationFence(current) + this.sender.releaseRotationFence(owned) return false } if (mode === 'legacy-owner') { - if (current) { - this.session.cancelDelivery(current.identity, 'source-credit-disabled') - this.sender.wakeSendWaiters(current) + if (owned) { + this.session.cancelDelivery(owned.identity, 'source-credit-disabled') + this.sender.wakeSendWaiters(owned) this.deliveries.delete(id) this.onCapacity(id) } @@ -88,7 +92,7 @@ export class RelayPtySourcePublication { current?.clientId === context.clientId && !current.restoreRequired && current.sourceExitState !== 'pending' && - this.deliveryClosedUnderRecord(current) + ptySourceDeliveryClosed(this.session, current.identity) ) { // Why: a canceled delivery can never resume as 'existing'; retire it so re-attach opens fresh. this.sender.wakeSendWaiters(current) @@ -219,7 +223,7 @@ export class RelayPtySourcePublication { } if (!output.sourceAccepted && !appendPtySourceOutput(this.session, record, output)) { this.counters.appendDenied++ - if (this.deliveryClosedUnderRecord(record)) { + if (ptySourceDeliveryClosed(this.session, record.identity)) { this.sender.wakeSendWaiters(record) this.deliveries.delete(id) // Why: deferred — publish() can run inside flushPendingOutput's captured-queue drain, @@ -275,10 +279,6 @@ export class RelayPtySourcePublication { this.sender.dispose() } - private deliveryClosedUnderRecord(record: RelayPtySourceDeliveryRecord): boolean { - return ptySourceDeliveryClosed(this.session, record.identity) - } - private registerActivationSettlement( id: string, record: RelayPtySourceDeliveryRecord, diff --git a/src/relay/relay-pty-source-superseded-activation.test.ts b/src/relay/relay-pty-source-superseded-activation.test.ts new file mode 100644 index 00000000000..759bbbef1f5 --- /dev/null +++ b/src/relay/relay-pty-source-superseded-activation.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + RelayDispatcher, + type RelayClientSessionIdentity, + type RequestContext, + type SinkWriteSettlement +} from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +function requestFrame(id: number, method: string, params: Record): Buffer { + return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) +} + +function responseResult(buffer: Buffer): Record | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + const message = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) + return message.id === undefined ? null : (message.result ?? null) +} + +async function flushRequests(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('PTY source activation from a superseded owner', () => { + let dispatcher: RelayDispatcher | null = null + + afterEach(() => { + dispatcher?.dispose() + dispatcher = null + }) + + async function createHarness() { + const writes: Buffer[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + let publication: RelayPtySourcePublication + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => + publication.onCreditAvailable(id) + ) + publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) + dispatcher.feed( + requestFrame(1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } + }) + ) + await flushRequests() + return { adapter, publication, writes } + } + + function contextFor( + clientId: number, + settlements: ((result: SinkWriteSettlement) => void)[] + ): RequestContext { + return { + clientId, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => settlements.push(callback) + } + } + + /** + * The superseded transport must not release, cancel or retire the delivery its own replacement + * opened: releasing the fence resumes a send the replacement is still rotating, and retiring it + * blanks the pane that owns it. + */ + it('leaves the replacement delivery intact when the superseded owner re-activates', async () => { + const { publication, adapter, writes } = await createHarness() + const settlements: ((result: SinkWriteSettlement) => void)[] = [] + expect(publication.activate('pty-1', 'incarnation-1', contextFor(1, settlements))).toBe( + 'opened' + ) + settlements[0]({ ok: true }) + const activation = publication.receivingActivation('pty-1', 1)! + const ownerGrant = writes.map(responseResult).find((result) => result?.ownerLease)! + + // The original transport arms its rotation fence while waiting for a checkpoint-safe send. + await expect(publication.waitForPendingSend('pty-1')).resolves.toBe(true) + + const replacementWrites: Buffer[] = [] + const replacementClientId = dispatcher!.attachClient( + (data, onSettled) => { + replacementWrites.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + dispatcher!.feedClient( + replacementClientId, + requestFrame(2, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + resume: { + ownerGeneration: ownerGrant.ownerGeneration, + ownerLease: ownerGrant.ownerLease + }, + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 4 } } + }) + ) + await flushRequests() + + const replacementSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const recovery = { + status: 'checkpoint' as const, + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + ptyIncarnation: activation.ptyIncarnation, + deliveryToken: activation.deliveryToken, + acceptedSourceEndSu: 0 + } + expect( + publication.activate( + 'pty-1', + 'incarnation-1', + contextFor(replacementClientId, replacementSettlements), + recovery + ) + ).toMatchObject({ status: 'pending' }) + replacementSettlements[0]({ ok: true }) + + // Arm the replacement fence, then let the superseded transport's activation resume. isStale() + // stays false on purpose: the superseded client is still attached, so production reaches the + // delivery-mode bail-outs rather than any stale early-out. + await expect(publication.waitForPendingSend('pty-1')).resolves.toBe(true) + const cancelDelivery = vi.spyOn(adapter, 'cancelDelivery') + expect(publication.activate('pty-1', 'incarnation-1', contextFor(1, []), recovery)).toBe(false) + expect(cancelDelivery).not.toHaveBeenCalled() + expect(publication.publish('pty-1', { data: 'replacement-output' }, false)).toBe(false) + + // Release the replacement fence through its owning transport so no parked work is left behind. + expect( + publication.activate('pty-1', 'incarnation-1', contextFor(replacementClientId, [])) + ).toBe('existing') + expect(replacementWrites.length).toBeGreaterThan(0) + }) +}) diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index 36276ed9b70..485c9e787d1 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -6,6 +6,7 @@ import { RelayContext, expandTilde } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' +import { GitResponseStreamRegistry } from './git-response-stream' import { PreflightHandler } from './preflight-handler' import { ExternalAutomationsHandler } from './external-automations-handler' import { PortScanHandler } from './port-scan-handler' @@ -51,13 +52,17 @@ export class RelayRuntimeServices { ) this.ptyHandler.setSourcePublication(this.ptySourcePublication) - this.fsHandler = new FsHandler(dispatcher, context) + // Why one instance for both handlers: a client reassembles a streamed reply by `streamId` alone, + // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck` + // credit every pump waits on. A second registry is not an option — see git-response-stream.ts. + const responseStreams = new GitResponseStreamRegistry() + this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) const watchRegistry = this.fsHandler.getWatchRegistry() this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) watchRegistry.setWorktreePtyTeardown((rootPath) => this.ptyHandler.shutdownForWorktreePath(rootPath) ) - this.gitHandler = new GitHandler(dispatcher, context, watchRegistry) + this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams) const preflightHandler = new PreflightHandler(dispatcher) this.skillInstallHandler = new SkillInstallHandler(dispatcher) const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 99915597e67..bb60f898130 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -5,6 +5,7 @@ import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to- import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { requestTerminalTabRename } from '../components/tab-bar/terminal-tab-rename-request' import { deleteHoveredWorkspaceImmediately, resolveHoveredWorkspaceDeleteTarget @@ -180,7 +181,7 @@ export function createAppCommandHandlers( ) { return false } - return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) + return claim('tab.rename', () => requestTerminalTabRename(store.activeTabId!)) } ], [ diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts index f4940db6ab5..cf33aed8e3e 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' describe('reconcileHydratedWorkspaceTabModels', () => { - it('reconciles every workspace the session hydrated, in session order', () => { + it('reconciles every workspace the session hydrated, in session order, in one call', () => { const reconcile = vi.fn() const reconciled = reconcileHydratedWorkspaceTabModels( { tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } }, reconcile ) - expect(reconcile.mock.calls.map((call) => call[0])).toEqual(['wt-a', 'wt-b', 'wt-c']) + expect(reconcile).toHaveBeenCalledTimes(1) + expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c']) expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c']) }) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts index 84704b3eb2d..3da45244660 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts @@ -3,12 +3,13 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st /** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */ export function reconcileHydratedWorkspaceTabModels( session: Pick, - reconcileWorktreeTabModel: (worktreeId: string) => unknown + // Why batched: one store write for the whole session instead of one per + // workspace, each fanning out to every non-React store subscriber. + reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void ): string[] { - const reconciled: string[] = [] - for (const worktreeId of Object.keys(session.tabsByWorktree)) { - reconcileWorktreeTabModel(worktreeId) - reconciled.push(worktreeId) + const reconciled = Object.keys(session.tabsByWorktree) + if (reconciled.length > 0) { + reconcileWorktreeTabModels(reconciled) } return reconciled } diff --git a/src/renderer/src/app-shell/startup-actions-selector.test.ts b/src/renderer/src/app-shell/startup-actions-selector.test.ts index 1d559eb0dcb..45a1b9cf5df 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.test.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.test.ts @@ -30,6 +30,7 @@ function makeActions(): StartupActions { reconnectPersistedTerminals: vi.fn(), setTerminalStartupRestorationReady: vi.fn(), setDeferredSshReconnectTargets: vi.fn(), + removeDeferredSshReconnectTarget: vi.fn(), setSshConnectionState: vi.fn(), hydratePersistedUI: vi.fn(), setHydrationSucceeded: vi.fn(), diff --git a/src/renderer/src/app-shell/startup-actions-selector.ts b/src/renderer/src/app-shell/startup-actions-selector.ts index c7dca311a03..ac18349909a 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.ts @@ -22,6 +22,7 @@ export type StartupActions = Pick< | 'reconnectPersistedTerminals' | 'setTerminalStartupRestorationReady' | 'setDeferredSshReconnectTargets' + | 'removeDeferredSshReconnectTarget' | 'setSshConnectionState' | 'hydratePersistedUI' | 'setHydrationSucceeded' @@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions { cachedStartupActions.setTerminalStartupRestorationReady === state.setTerminalStartupRestorationReady && cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets && + cachedStartupActions.removeDeferredSshReconnectTarget === + state.removeDeferredSshReconnectTarget && cachedStartupActions.setSshConnectionState === state.setSshConnectionState && cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI && cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded && @@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions { reconnectPersistedTerminals: state.reconnectPersistedTerminals, setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady, setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget, setSshConnectionState: state.setSshConnectionState, hydratePersistedUI: state.hydratePersistedUI, setHydrationSucceeded: state.setHydrationSucceeded, diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 2b5272f440d..91db232081c 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -19,6 +19,7 @@ import { } from '../startup/startup-diagnostics' import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' +import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' ) - // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. - await timeRendererStartupStep('first-window-services-await', () => - window.api.app.awaitFirstWindowStartupServices() + // Why this barrier and not the first-window one: worktree refresh can spawn host Git, + // which needs the shell-PATH generation and the managed WSL CLI registration. It never + // needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration` + // below still fences those before any terminal is restored. + await timeRendererStartupStep('git-environment-barrier-await', () => + window.api.app.awaitGitEnvironmentStartupBarrier() ) await timeRendererStartupStep('fetch-hydration-worktrees', () => mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => @@ -198,7 +202,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) reconcileHydratedWorkspaceTabModels( sessionRead.session, - useAppStore.getState().reconcileWorktreeTabModel + useAppStore.getState().reconcileWorktreeTabModels ) }) await timeRendererStartupStep('prepare-terminal-startup-restoration', () => @@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.pruneLastVisitedTimestamps() actions.seedActiveWorktreeLastVisitedIfMissing() }) - await timeRendererStartupStep('fetch-browser-session-profiles', () => + // Why started here but not awaited: on a remote runtime this is an RPC with a 15s + // timeout, and nothing between here and terminal restoration reads the profile list — + // awaiting it put that timeout on the terminal-restoration gate. Starting it at the + // original point keeps the profiles landing no later than they did before; the action + // swallows its own failures, so the `.catch` only marks the timing wrapper handled. + void timeRendererStartupStep('fetch-browser-session-profiles', () => actions.fetchBrowserSessionProfiles() - ) + ).catch(() => {}) const onboardingState = await onboardingPromise if (!cancelled) { onOnboardingLoadedRef.current(onboardingState) @@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta ) if (connectionIds.length > 0) { try { + // Why scoped: an unreachable host used to hold every restored terminal — local ones + // included — for the full reconnect timeout. Only the targets whose panes mount as + // soon as the gate opens are worth waiting for; the rest reattach on tab focus. + const blockingConnectionIds = collectActiveWorkspaceSshTargetIds( + useAppStore.getState() + ) await restoreSshConnectionsForStartup({ connectionIds, + blockingConnectionIds, setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget, publishSshConnectionState: actions.setSshConnectionState }) } catch (err) { @@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) } - // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. + // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited + // the first-window services, and main re-awaits them inside this handler anyway. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) @@ -269,6 +287,16 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk. actions.setHydrationSucceeded(true) actions.setTerminalStartupRestorationReady(true) + // Why the explicit opt-in: unconditional seeding hijacks every empty dev + // profile's active workspace, making onboarding/empty-state flows untestable. + if ( + import.meta.env.DEV && + String(import.meta.env.VITE_ACTIVITY_DEV_FIXTURE).toLowerCase() === 'true' + ) { + const { seedDevActivityFixture } = + await import('../components/activity/dev-activity-fixture') + seedDevActivityFixture() + } logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 857b55fc8a4..19109c25eb2 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -167,7 +167,11 @@ export function usePersistedUIWriter(): void { // paths in agent-status.ts (close/dismiss) flow to disk through map identity changes. // Without persisting, agent rows that survive restart come back bold even when the // user had already visited them. - acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, + // Why: "Clear completed" must survive restart, or cleared done/interrupted rows return. + activityClearedAtByPaneKey: s.activityClearedAtByPaneKey, + // Why: an explicit "mark unread" must survive restart, or the row comes back read. + manuallyUnreadTurnsByPaneKey: s.manuallyUnreadTurnsByPaneKey })) ) useEffect(() => { diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 24a9cc75120..d1aad35829a 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -68,8 +68,11 @@ describe('renderer startup runtime routing', () => { const hydrationWorktreesIndex = source.indexOf( "timeRendererStartupStep('fetch-hydration-worktrees'" ) - const servicesIndex = source.indexOf( - "timeRendererStartupStep('first-window-services-await'", + // Why this barrier: worktree hydration can spawn host Git, so it must sit behind the + // shell-PATH + managed-WSL fence. On packaged Windows the window opens before + // shellPathReady resolves, so this really is the fence, not a formality. + const gitEnvironmentBarrierIndex = source.indexOf( + "timeRendererStartupStep('git-environment-barrier-await'", sessionIndex ) const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()') @@ -89,8 +92,11 @@ describe('renderer startup runtime routing', () => { expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) expect(localReposIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(servicesIndex) - expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex) + expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex) + expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex) + expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain( + 'window.api.app.awaitGitEnvironmentStartupBarrier()' + ) const hydrationWorktreeBlock = source.slice( hydrationWorktreesIndex, source.indexOf('await keybindingsPromise') @@ -180,7 +186,13 @@ describe('renderer startup runtime routing', () => { it('waits for first-window startup services before terminal reconnect', () => { const source = readSource(STARTUP_HYDRATION_PATH) - const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'") + // Why this step: `app:prepareTerminalStartupRestoration` awaits + // firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it + // does anything else, so it is the renderer-side position of that fence. + // `desktop-startup-ordering.test.ts` pins the main-side await itself. + const servicesIndex = source.indexOf( + "timeRendererStartupStep('prepare-terminal-startup-restoration'" + ) const preReconnectRecoveryIndex = source.indexOf( "timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'" ) @@ -193,6 +205,9 @@ describe('renderer startup runtime routing', () => { ) expect(servicesIndex).toBeGreaterThanOrEqual(0) + expect(source.slice(servicesIndex)).toContain( + 'window.api.app.prepareTerminalStartupRestoration()' + ) expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex) expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex) expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 1b5f40ccdb4..8187fe496c7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1956,7 +1956,9 @@ html.native-shell .app-layout { transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1), width 120ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 80ms ease-out; - will-change: transform, width, opacity; + /* Why no `width`: it is not compositable, so hinting it only pins a layer that + has to be re-rastered every frame of the transition anyway. */ + will-change: transform, opacity; } [data-workspace-board-card-drop-indicator='true']::before, diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index ab84e4562d8..33642542d79 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -93,6 +93,15 @@ describe('AgentStateDot', () => { } ) + it('renders unverifiable as an amber dashed ring, never the done check or the spinner', () => { + const markup = renderMarkup('unverifiable') + + expect(markup).toContain('lucide-circle-dashed') + expect(markup).toContain('text-amber-500') + expect(markup).not.toContain('lucide-circle-check') + expect(markup).not.toContain('data-agent-spinner') + }) + it.each(['blocked', 'interrupted'] satisfies AgentDotState[])( 'renders %s as a red attention dot', (state) => { @@ -112,6 +121,7 @@ describe('AgentStateDot', () => { 'failed', 'done', 'idle', + 'unverifiable', 'permission' ] satisfies AgentDotState[] diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index bcb44a8e726..af8ac4efd84 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Activity, CircleCheck } from 'lucide-react' +import { Activity, CircleCheck, CircleDashed } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -30,6 +30,11 @@ export type AgentDotState = | 'failed' | 'done' | 'idle' + // Why: the pane still has a live PTY but its reporting stream has gone quiet past + // the staleness window. Distinct from 'idle' because Orca has evidence something is + // held there, and never rendered as 'done' or 'working' — it asserts nothing about + // the agent, only about what Orca last heard. + | 'unverifiable' // Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard) // collapses blocked + waiting into a single "needs attention" state. Keep // this as a distinct member so that flow can render without inventing a new @@ -56,6 +61,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Done' case 'idle': return 'Idle' + case 'unverifiable': + return 'No recent update' case 'permission': return 'Needs attention' } @@ -116,6 +123,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({