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..c17ad60d5d3 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 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..b486a9a4925 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). 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 1a31af9dfaf..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,12 +105,41 @@ 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 +// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. +const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] /** @type {import('electron-builder').Configuration} */ module.exports = { appId, productName: 'Orca', protocols: [{ name: 'Orca', schemes: ['orca'] }], + toolsets: { appimage: '1.0.3' }, ...(devChannelBuildVersion ? { extraMetadata: { version: devChannelBuildVersion } } : localBuildVersion @@ -230,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' @@ -249,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] @@ -268,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 @@ -376,12 +420,24 @@ module.exports = { shortcutName: '${productName}', uninstallDisplayName: '${productName}', createDesktopShortcut: 'always', - // Why: on a real uninstall, stop and remove the relocated terminal daemon - // (which lives outside the install dir under LOCALAPPDATA by design). Guarded - // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. - include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') + // Why: electron-builder allows one include, so both Windows installer hooks live in it - + // the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an + // update's uninstallOldVersion) and the additive markdown "Open with" registration. + // Windows markdown association is deliberately NOT done via `fileAssociations`; see the + // header comment in that file for why that would steal the user's default .md handler. + include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh') }, mac: { + // Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming + // LSHandlerRank ownership, so whichever editor the user already prefers stays the default. + // Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break. + fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: 'Markdown Document', + description: 'Markdown Document', + role: 'Editor', + rank: 'Alternate' + })), icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', @@ -468,6 +524,12 @@ module.exports = { artifactName: 'orca-macos-${arch}.${ext}' }, linux: { + // Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to + // text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob + // override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the + // default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to + // application/x-genesis-32x-rom, so claiming it here would need a glob override. + mimeTypes: ['text/markdown'], // Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca. // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', @@ -499,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' }, @@ -513,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', @@ -534,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' @@ -561,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/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh deleted file mode 100644 index dc3a497ce67..00000000000 --- a/config/nsis/daemon-host-uninstall.nsh +++ /dev/null @@ -1,23 +0,0 @@ -; Clean up the relocated terminal daemon on a REAL uninstall. -; -; Why: the daemon host is deliberately copied to a distinct image name -; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app -; UPDATES cannot kill it — that relocation is what keeps terminals alive across -; updates. The same design means a normal uninstall's process sweep and file -; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. -; -; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as -; part of uninstallOldVersion on EVERY update, and killing the daemon there would -; defeat the whole feature. Only clean up on a genuine uninstall. -; -; The image name and the LOCALAPPDATA folder name must stay in sync with -; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in -; src/main/daemon/daemon-host-relocation.ts. -!macro customUnInstall - ${ifNot} ${isUpdated} - nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' - ; Give the OS a moment to release the image lock before removing the tree. - Sleep 500 - RMDir /r "$LOCALAPPDATA\Orca\daemon-host" - ${endIf} -!macroend diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh new file mode 100644 index 00000000000..ca80c99fc6d --- /dev/null +++ b/config/nsis/orca-installer-hooks.nsh @@ -0,0 +1,79 @@ +; electron-builder NSIS hooks for the Orca Windows installer. +; +; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall / +; customUnInstall hook Orca needs lives here. + +; --------------------------------------------------------------------------- +; Markdown "Open with Orca" (issue #10138) +; +; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows: +; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is +; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "" +; That overwrites whichever editor currently owns .md, with no backup, for every +; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so +; uninstalling Orca would leave .md pointing at a deleted ProgID. +; +; These writes are additive only. Registering a ProgID plus an OpenWithProgids +; hint and an Applications\\SupportedTypes entry puts Orca in Explorer's +; "Open with" list and in "Choose another app", while the default handler stays +; exactly where the user left it. Never add a `Software\Classes\.` default +; value here. +; +; MARKDOWN_PROGID must stay in sync with the extension list handled by +; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts. +; --------------------------------------------------------------------------- +!define MARKDOWN_PROGID "Orca.Markdown" + +!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT + WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" "" +!macroend + +!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT + DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" +!macroend + +!macro customInstall + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"' + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx" + ; Why: Explorer caches the association list until told otherwise. + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend + +; --------------------------------------------------------------------------- +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} + ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so + ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. + DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx" + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend 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..83de3128ecf 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -16701,16 +16701,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 +16719,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 +16737,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 +16760,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 +16788,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 +16828,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/__fixtures__/websocket-server-wildcard-bind-allowlist.txt b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt new file mode 100644 index 00000000000..4b76622a9f2 --- /dev/null +++ b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt @@ -0,0 +1,14 @@ +# Files allowed to construct a `ws` server that binds a port without pinning `host`. +# +# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server +# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback +# listener can hold the same port and answer in its place -- which is how +# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that +# was simulating silence. +# +# This list only shrinks. Adding a line also requires raising the pin in +# websocket-server-loopback-bind.test.ts, which is deliberate friction. + +# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to +# be reachable on a real interface. A loopback bind would make it unreachable. +mobile/scripts/mock-server.ts 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/call-site-option-keys.ts b/config/scripts/call-site-option-keys.ts new file mode 100644 index 00000000000..dff3a971c45 --- /dev/null +++ b/config/scripts/call-site-option-keys.ts @@ -0,0 +1,204 @@ +/** + * Read the top-level option keys of a call's object-literal argument out of raw + * source text. + * + * Text rather than an AST because typescript@7 no longer ships the classic + * compiler API and every installed parser is a transitive dependency. The + * tradeoff is handled by refusing to guess: any shape this cannot read comes + * back as `unreadable` with a reason, and callers must treat that as a failure + * rather than as an absence of keys. + */ + +export type CallOptionKeys = + | { readonly readable: true; readonly keys: readonly string[] } + | { readonly readable: false; readonly reason: string } + +type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template' + +function closesString(state: ScanState, current: string): boolean { + return ( + (state === 'single' && current === "'") || + (state === 'double' && current === '"') || + (state === 'template' && current === '`') + ) +} + +function opensNonCode(current: string, next: string | undefined): ScanState | null { + if (current === '/' && next === '/') { + return 'line' + } + if (current === '/' && next === '*') { + return 'block' + } + if (current === "'") { + return 'single' + } + if (current === '"') { + return 'double' + } + if (current === '`') { + return 'template' + } + return null +} + +/** + * Text between an open paren and its match, tracking strings and comments so a + * brace inside either cannot unbalance the count. Null when it never closes. + */ +function balancedArguments(text: string, openIndex: number): string | null { + let depth = 0 + let state: ScanState = 'code' + for (let index = openIndex; index < text.length; index++) { + const current = text[index] + const next = text[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (depth === 0) { + return text.slice(openIndex + 1, index) + } + if (depth < 0) { + return null + } + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + // Brace tracking inside `${}` would need its own depth; templates never + // appear as options, so report one as unreadable instead of guessing. + if (state === 'template' && current === '$' && next === '{') { + return null + } + if (closesString(state, current)) { + state = 'code' + } + } + return null +} + +/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */ +function objectLiteralKeys(body: string): string[] { + const keys: string[] = [] + let depth = 0 + let state: ScanState = 'code' + let inValue = false + let token = '' + const flush = (): void => { + const name = token.trim() + token = '' + if (name && depth === 0) { + keys.push(name) + } + } + for (let index = 0; index < body.length; index++) { + const current = body[index] + const next = body[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + if (!inValue) { + token += current + } + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (!inValue) { + token += current + } + } else if (current === ':' && depth === 0 && !inValue) { + flush() + inValue = true + } else if (current === ',' && depth === 0) { + // A shorthand or a spread ends here having never seen a colon. + if (inValue) { + inValue = false + token = '' + } else { + flush() + } + } else if (!inValue) { + token += current + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + if (closesString(state, current)) { + state = 'code' + } + } + if (!inValue) { + flush() + } + return keys +} + +/** + * Option keys of the call whose argument list opens at `parenIndex`, or the + * reason the shape could not be read. Spreads and computed keys land in the + * latter: either can carry a key this would otherwise report as absent. + */ +export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys { + const args = balancedArguments(text, parenIndex) + if (args === null) { + return { readable: false, reason: 'argument list never closes' } + } + if (!args.trim()) { + return { readable: false, reason: 'called with no options argument' } + } + const trimmed = args.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return { readable: false, reason: 'options are not an object literal' } + } + const keys = objectLiteralKeys(trimmed.slice(1, -1)) + const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key)) + if (unreadable !== undefined) { + return { readable: false, reason: `unreadable option key \`${unreadable}\`` } + } + return { readable: true, keys } +} 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-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs new file mode 100644 index 00000000000..7ae3b1c9428 --- /dev/null +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -0,0 +1,116 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { basename } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx'] + +// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("") +// value of Software\Classes\.. Additive `WriteRegNone ...\OpenWithProgids` must not +// match, or the guard below would be unfalsifiable. +const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i + +// The hooks file documents the forbidden line in prose, so match executable script only. +const stripNsisCommentLines = (source) => + source + .split('\n') + .filter((line) => !/^\s*[;#]/.test(line)) + .join('\n') + +const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8') + +describe('electron-builder markdown file associations', () => { + // Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS + // packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value + // — silently taking .md from whichever editor owns it, for every existing user on their + // next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot + // prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must + // stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks. + it('never claims the Windows default markdown handler', () => { + expect(electronBuilderConfig.fileAssociations).toBeUndefined() + expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined() + }) + + it('joins the macOS Open With list for every markdown extension without owning it', () => { + const associations = electronBuilderConfig.mac.fileAssociations + // One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob. + expect([...associations].map((association) => association.ext).sort()).toEqual( + [...MARKDOWN_EXTENSIONS].sort() + ) + for (const association of associations) { + expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' }) + } + }) + + // Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to + // text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning + // the default. A fileAssociations entry would ship a redundant glob override instead. + it('reuses the existing shared-mime-info markdown type on Linux', () => { + expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown') + expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined() + }) + + it('points the single NSIS include at the installer hooks file on disk', () => { + const includePath = electronBuilderConfig.nsis.include + expect(existsSync(includePath)).toBe(true) + expect(basename(includePath)).toBe('orca-installer-hooks.nsh') + }) + + // Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so + // the assertion below is a live check rather than a regex that can never fire. + it('recognizes an APP_ASSOCIATE-style default-handler write', () => { + for (const takeover of [ + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"', + 'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"' + ]) { + expect(takeover).toMatch(DEFAULT_HANDLER_WRITE) + } + expect( + 'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"' + ).not.toMatch(DEFAULT_HANDLER_WRITE) + // Comment stripping must drop prose that quotes the bad line without swallowing a real + // one that happens to carry a trailing comment. + const stripped = stripNsisCommentLines( + [ + '; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" ""', + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops' + ].join('\n') + ) + expect(stripped.split('\n')).toHaveLength(1) + expect(stripped).toMatch(DEFAULT_HANDLER_WRITE) + }) + + it('registers Windows markdown Open With additively, never as the default', async () => { + const hooks = await readInstallerHooks() + + expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE) + // The additive hint that puts Orca in Explorer's "Open with" list. + expect(hooks).toMatch( + /WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/ + ) + expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/) + for (const ext of MARKDOWN_EXTENSIONS) { + expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + } + expect(hooks).toMatch(/!macro\s+customInstall\b/) + expect(hooks).toMatch(/!macro\s+customUnInstall\b/) + }) + + // Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown + // hooks too. electron-builder allows only one include, so a merge that drops the daemon + // sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall. + it('keeps the daemon-host uninstall sweep across the include rename', async () => { + const hooks = await readInstallerHooks() + + expect(hooks).toContain('orca-terminal-daemon.exe') + expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host') + // Without this guard, uninstallOldVersion would kill the daemon on every update — + // defeating the relocation that keeps terminals alive across updates. + expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) + }) +}) 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 8945c7b9f8a..950d5ed258a 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -363,6 +363,10 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).toContain('chrome-sandbox') expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') + expect(afterInstallScript).toContain('is_owned_link()') + expect(afterInstallScript).toContain('readlink -f -- "$link"') + expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]') + expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]') }) it('advances only the skill release ledger in a taggable release-cut commit', () => { @@ -651,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..232780c6941 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -266,6 +266,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/space-sharing-copy.mjs b/config/scripts/space-sharing-copy.mjs index 191bd8bffdc..01e9c8ac5ef 100644 --- a/config/scripts/space-sharing-copy.mjs +++ b/config/scripts/space-sharing-copy.mjs @@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) { chmod(targetPath, 0o755) } +/** + * Restore owner write permission across a private copy. + * + * Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode + * across, so a tree copied from the write-protected shared cache lands read-only and every patch + * the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit + * comes back; group and other stay as the source left them. + */ +export function makeTreeWritable(targetPath, chmod = chmodSync) { + for (const entry of readdirSync(targetPath, { withFileTypes: true })) { + const entryPath = join(targetPath, entry.name) + if (entry.isDirectory()) { + makeTreeWritable(entryPath, chmod) + } else if (!entry.isSymbolicLink()) { + const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode + chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200) + } + } + chmod(targetPath, 0o755) +} + /** * Share storage when possible, otherwise copy the bytes. * * Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write - * through into the source. + * through into the source. The copy is unprotected on the way out for the same reason -- a private + * tree the caller cannot write to is useless to it. */ export function copyPrivateTree(sourcePath, destinationPath, options = {}) { const platform = options.platform ?? process.platform const copy = options.copy ?? copyTreeVerbatim + const unprotect = options.unprotect ?? makeTreeWritable const privateMechanisms = new Set(['clone', 'reflink']) + let result = { mechanism: null, copyError: null } if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) { try { - const mechanism = shareTree(sourcePath, destinationPath, { - ...options, - hardlink: () => { - throw new Error('hardlinks would not be private') - } - }) - return { mechanism, copyError: null } + result = { + mechanism: shareTree(sourcePath, destinationPath, { + ...options, + hardlink: () => { + throw new Error('hardlinks would not be private') + } + }), + copyError: null + } } catch (copyError) { copy(sourcePath, destinationPath) - return { mechanism: null, copyError } + result = { mechanism: null, copyError } } + } else { + copy(sourcePath, destinationPath) } - copy(sourcePath, destinationPath) - return { mechanism: null, copyError: null } + unprotect(destinationPath) + return result } function copyTreeVerbatim(sourcePath, destinationPath) { diff --git a/config/scripts/space-sharing-copy.test.ts b/config/scripts/space-sharing-copy.test.ts index 3ce35aae25e..351f44b286e 100644 --- a/config/scripts/space-sharing-copy.test.ts +++ b/config/scripts/space-sharing-copy.test.ts @@ -19,6 +19,7 @@ import { copyPrivateTree, hardlinkTree, makeTreeReadOnly, + makeTreeWritable, shareTree } from './space-sharing-copy.mjs' @@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => { ) }) +describe('makeTreeWritable', () => { + it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => { + const { source } = makeTree() + makeTreeReadOnly(source) + makeTreeWritable(source) + const file = path.join(source, 'nested', 'file') + expect(statSync(file).mode & 0o200).toBe(0o200) + expect(() => writeFileSync(file, 'mutated')).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => { + const { source } = makeTree() + const executable = path.join(source, 'electron') + writeFileSync(executable, 'binary') + chmodSync(executable, 0o555) + makeTreeWritable(source) + expect(statSync(executable).mode & 0o777).toBe(0o755) + }) +}) + describe('copyPrivateTree', () => { + it.runIf(process.platform !== 'win32')( + 'hands back a tree the caller can patch, even from a write-protected source', + () => { + const { root, source } = makeTree() + const destination = path.join(root, 'private') + makeTreeReadOnly(source) + copyPrivateTree(source, destination) + // The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync + // all carry that across, and `pn dev` then died patching the copied bundle's Info.plist. + expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow() + expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents') + } + ) + it('never hardlinks, because the caller patches what it gets back', () => { const { root, source } = makeTree() const destination = path.join(root, 'private') 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/websocket-server-bind-scan.ts b/config/scripts/websocket-server-bind-scan.ts new file mode 100644 index 00000000000..9054f8cc38e --- /dev/null +++ b/config/scripts/websocket-server-bind-scan.ts @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { readCallOptionKeys } from './call-site-option-keys' + +/** + * Locate every `new WebSocketServer(...)` in the tree and say, for each, whether + * it pins a bind address. + * + * `ws` accepts `{ port }` alone and silently binds the wildcard address, so a + * server the caller then dials on 127.0.0.1 sits at a port a foreign loopback + * listener can also hold -- and the more specific listener wins the connection, + * answering in that server's place. + * + * Anything unreadable is reported as `opaque` rather than skipped. A matcher + * that silently exempts the shapes it fails to parse is worse than no matcher, + * because it reads as coverage. + */ + +export type BindSite = { path: string; line: number } +export type OpaqueSite = BindSite & { reason: string } + +export type WebSocketServerBindScan = { + filesScanned: number + /** Every construction recognized, however it was then classified. */ + constructions: number + /** Binds a port with no `host`: reachable at an address the dialer never named. */ + wildcardBound: BindSite[] + /** Shape that could not be read; never treated as safe. */ + opaque: OpaqueSite[] + /** Binds a port and pins `host`. */ + loopbackBound: BindSite[] + /** No `port`: attaches to a server that owns the bind itself. */ + attached: BindSite[] +} + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__', + 'coverage', + // Full snapshots of older releases; their bind sites are not this tree's to fix. + '.cross-version-checkouts' +]) +const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/ +const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests'] +const WS_IMPORT_HINT = /from\s*['"]ws['"]/ + +function collectSourceFiles(root: string, found: string[] = []): string[] { + let entries: ReturnType> + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry.name)) { + continue + } + const full = join(root, entry.name) + if (entry.isDirectory()) { + collectSourceFiles(full, found) + } else if (SCANNED_EXTENSIONS.test(entry.name)) { + found.push(full) + } + } + return found +} + +/** Local names bound to ws's server class, following `as` aliases and namespace imports. */ +function webSocketServerNames(text: string): { direct: Set; namespaces: Set } { + const direct = new Set() + const namespaces = new Set() + // One statement at a time: a pattern reaching for `from 'ws'` would swallow + // every import above it and lose the specifier names in the blob. + for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) { + if (match[3] !== 'ws') { + continue + } + const clause = match[1] + if (/^\s*type\b/.test(clause)) { + continue + } + const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/) + if (namespace) { + namespaces.add(namespace[1]) + } + const named = clause.match(/\{([\s\S]*)\}/) + if (!named) { + continue + } + for (const specifier of named[1].split(',')) { + const trimmed = specifier.trim() + if (!trimmed || /^type\s/.test(trimmed)) { + continue + } + const parts = trimmed.split(/\s+as\s+/) + // `Server` is ws's own alias for WebSocketServer. + if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') { + direct.add((parts[1] ?? parts[0]).trim()) + } + } + } + return { direct, namespaces } +} + +function classify( + scan: WebSocketServerBindScan, + site: BindSite, + text: string, + paren: number +): void { + const options = readCallOptionKeys(text, paren) + if (!options.readable) { + scan.opaque.push({ ...site, reason: options.reason }) + return + } + if (!options.keys.includes('port')) { + scan.attached.push(site) + return + } + if (!options.keys.includes('host')) { + scan.wildcardBound.push(site) + return + } + scan.loopbackBound.push(site) +} + +export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan { + const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory))) + const scan: WebSocketServerBindScan = { + filesScanned: files.length, + constructions: 0, + wildcardBound: [], + opaque: [], + loopbackBound: [], + attached: [] + } + for (const file of files) { + const text = readFileSync(file, 'utf8') + // Filter on the import, not on the class name: `Server as Wss` never spells + // WebSocketServer, and keying on that name silently skipped the whole alias. + if (!WS_IMPORT_HINT.test(text)) { + continue + } + const { direct, namespaces } = webSocketServerNames(text) + if (!direct.size && !namespaces.size) { + continue + } + const path = relative(repoRoot, file).split('\\').join('/') + const patterns = [ + ...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')), + ...[...namespaces].map( + (name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g') + ) + ] + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + scan.constructions++ + const line = text.slice(0, match.index).split('\n').length + classify(scan, { path, line }, text, match.index + match[0].length - 1) + } + } + } + return scan +} + +export function formatSites(sites: readonly BindSite[]): string[] { + return sites.map((site) => `${site.path}:${site.line}`) +} diff --git a/config/scripts/websocket-server-loopback-bind.test.ts b/config/scripts/websocket-server-loopback-bind.test.ts new file mode 100644 index 00000000000..9f32f8eda1a --- /dev/null +++ b/config/scripts/websocket-server-loopback-bind.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan' + +/** + * Hold the bind address at the tree level rather than per call site. + * + * Every one of the ~30 `.listen(0, ...)` calls in this repo already passes + * '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know + * the convention -- `ws` just never asks, because `{ port }` alone binds the + * wildcard without a word. That silence is what this test replaces. + * + * The allowlist only shrinks. A new wildcard bind fails here even where it looks + * harmless today, because harmless-looking is exactly what the seven were. + */ +/** The ratchet, held as data so it reads as the list it is. */ +const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync( + join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'), + 'utf8' +) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + +/** + * The true count of constructions that bind a port without pinning a host. + * + * May only ever be DECREASED, and only by pinning a host. Raising it is never + * the fix. + */ +const WILDCARD_BIND_PIN = 1 + +/** + * A floor under the constructions the scanner still recognizes. + * + * This is the guard against the scanner going blind: an import pattern it stops + * following reports zero offenders and reads exactly like a clean tree. During + * development a single wrong regex dropped this from 24 to 3. + */ +const RECOGNIZED_CONSTRUCTION_FLOOR = 20 + +describe('WebSocketServer loopback bind boundary', () => { + const repoRoot = resolve(__dirname, '..', '..') + const scan = scanWebSocketServerBinds(repoRoot) + const offenders = scan.wildcardBound.map((site) => site.path) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(scan.filesScanned).toBeGreaterThan(5_000) + }) + + it('still recognizes the known construction sites', () => { + expect( + scan.constructions, + `Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` + + `${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` + + 'shape rather than the tree having lost that many servers.' + ).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR) + }) + + it('can read the options of every construction it found', () => { + // An unreadable shape is never assumed safe: it could be hiding a host, or + // hiding the absence of one. Rewrite it as a plain object literal. + expect( + scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`), + 'WebSocketServer options that this guard cannot read.' + ).toEqual([]) + }) + + it('has no wildcard-bound server outside the allowlist', () => { + const unlisted = scan.wildcardBound.filter( + (site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path) + ) + expect( + formatSites(unlisted), + "New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " + + 'loopback listener cannot claim the port and answer in its place.' + ).toEqual([]) + }) + + it('has no stale allowlist entry', () => { + // Why this direction matters too: an entry left behind after the file was + // fixed hides the next regression in that same path. + const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path)) + expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([]) + }) + + it('holds the wildcard-bind count at the pin', () => { + // Bounding by the allowlist's own length would prove nothing: the two move + // together, so appending a line to silence a failure would keep the bound + // satisfied. The pin is a literal so that widening takes a second edit. + expect( + scan.wildcardBound.length, + `${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` + + `${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.` + ).toBeLessThanOrEqual(WILDCARD_BIND_PIN) + // A pin left above reality is how a ratchet rots: it re-opens room for the + // next wildcard bind to land for free. + expect( + scan.wildcardBound.length, + `Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` + + `WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN) + }) +}) 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 3004b8888ab..0e8b1f257d3 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -42,6 +42,17 @@ authority. | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | | `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +### Placeholders That Fail Open + +`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 | + ## Why Not `simple-git` `simple-git` is a process wrapper around the installed Git binary. Its custom diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index dc3fdf31da0..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 @@ -56,11 +60,25 @@ of the libraries installed. On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain `libfuse2` name also resolves there because nothing else provides it. FUSE is -optional: without it, use the AppImage's supported extraction path: +optional: without it, use the AppImage's supported extraction path. CLI +registration does this once automatically, so registered commands do not need +FUSE. + +Download and make the AppImage executable: + +```bash +sudo mkdir -p /opt/orca +sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ + -o /opt/orca/orca-linux.AppImage +sudo chmod +x /opt/orca/orca-linux.AppImage +``` + +To extract it without FUSE, run the extraction as root because the installation +directory is root-owned: ```bash cd /opt/orca -./orca-linux.AppImage --appimage-extract +sudo ./orca-linux.AppImage --appimage-extract sudo chmod -R a+rX /opt/orca/squashfs-root /opt/orca/squashfs-root/AppRun serve --port 6768 ``` @@ -76,15 +94,6 @@ extract-and-run wrapper can print extracted paths before Orca starts, so automation that requires stdout to contain only the ready JSON should extract once and invoke `squashfs-root/AppRun`. -Download and make the AppImage executable: - -```bash -sudo mkdir -p /opt/orca -sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ - -o /opt/orca/orca-linux.AppImage -sudo chmod +x /opt/orca/orca-linux.AppImage -``` - If `Xvfb` was installed somewhere other than `/usr/bin`, confirm systemd can find it later: @@ -224,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 @@ -319,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: @@ -336,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 @@ -371,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` @@ -383,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 @@ -872,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..d44aaf33e38 --- /dev/null +++ b/docs/reference/windows-edr-posture.md @@ -0,0 +1,401 @@ +# 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 +of two flag sets. Identity (`None | CreationTime`) answers pid/ppid/name from the +snapshot alone and opens nothing; the detailed set adds `CommandLine`, which +costs one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process. `Memory` +is retired — it took a second handle carrying `PROCESS_VM_READ` and never read +through it. + +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 since the split the module does: one +cache per flag set, so teardown identity and the session owner probe open no +handle at all (6.3 ms p50) while only the callers that read a command line pay +for one (12.3 ms p50, at 492 processes). Each cache still single-flights within +itself, and one gate serializes the native reads because the vendored wrapper +coalesces the flags of two overlapping calls. + +**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". The memory read is gone: the command line now +comes from the kernel, through `NtQueryInformationProcess`'s +`ProcessCommandLineInformation` class, which needs only +`PROCESS_QUERY_LIMITED_INFORMATION`. `ReadProcessMemory` is absent from the +compiled addon, asserted against the binary's import table because the published +prebuild loads fine and emits byte-identical strings. What is left to declare to +administrators is the per-process handle itself. + +### 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/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/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/docs/site/package.json b/docs/site/package.json index 9b5311fa04a..50ea76760fc 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -18,7 +18,7 @@ "fumadocs-mdx": "^14.3.1", "fumadocs-ui": "^16.8.4", "lucide-react": "^1.6.0", - "next": "16.2.1", + "next": "16.3.4", "react": "19.2.4", "react-dom": "19.2.4", "tailwind-merge": "^3.5.0", @@ -32,10 +32,10 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint-config-next": "16.3.4", "tailwindcss": "^4", "typescript": "^5", - "vercel": "50.37.0" + "vercel": "59.11.1" }, "engines": { "node": "22.x" @@ -46,6 +46,13 @@ "esbuild", "sharp", "unrs-resolver" - ] + ], + "overrides": { + "@vercel/fun>tar": "7.5.22", + "@vercel/fun>@tootallnate/once": "2.0.1", + "@vercel/node>undici": "5.29.0", + "@vercel/python-analysis>js-yaml": "4.3.2", + "@vercel/python-analysis>minimatch": "10.2.6" + } } } diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index 7cfd0ceee6f..4320f1bd617 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@vercel/fun>tar': 7.5.22 + '@vercel/fun>@tootallnate/once': 2.0.1 + '@vercel/node>undici': 5.29.0 + '@vercel/python-analysis>js-yaml': 4.3.2 + '@vercel/python-analysis>minimatch': 10.2.6 + importers: .: @@ -13,19 +20,19 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 version: 1.26.0(react@19.2.4) next: - specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -61,8 +68,8 @@ importers: specifier: ^9 version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: 16.2.1 - version: 16.2.1(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.3.4 + version: 16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.3.3 @@ -70,8 +77,8 @@ importers: specifier: ^5 version: 5.9.3 vercel: - specifier: 50.37.0 - version: 50.37.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) + specifier: 59.11.1 + version: 59.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) packages: @@ -175,8 +182,8 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -499,6 +506,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.2': resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -588,154 +601,152 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iarna/toml@2.2.5': - resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -764,62 +775,138 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@napi-rs/keyring-darwin-arm64@1.2.0': + resolution: {integrity: sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.2.0': + resolution: {integrity: sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.2.0': + resolution: {integrity: sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + resolution: {integrity: sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.2.0': + resolution: {integrity: sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.1': - resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/eslint-plugin-next@16.2.1': - resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==} + '@next/eslint-plugin-next@16.3.4': + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} - '@next/swc-darwin-arm64@16.2.1': - resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.1': - resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.1': - resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.1': - resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.1': - resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.1': - resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.1': - resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.1': - resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -844,9 +931,131 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.121.0': + resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.121.0': + resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.121.0': + resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.121.0': + resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.121.0': + resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.121.0': + resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.121.0': + resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.110.0': resolution: {integrity: sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==} + '@oxc-project/types@0.121.0': + resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} + '@oxc-transform/binding-android-arm-eabi@0.111.0': resolution: {integrity: sha512-NdFLicvorfHYu0g2ftjVJaH7+Dz27AQUNJOq8t/ofRUoWmczOodgUCHx8C1M1htCN4ZmhS/FzfSy6yd/UngJGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1455,8 +1664,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1546,13 +1755,10 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@ts-morph/common@0.11.1': resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} @@ -1778,105 +1984,186 @@ packages: cpu: [x64] os: [win32] - '@vercel/backends@0.0.51': - resolution: {integrity: sha512-rGuyw79vubB9VyXhb5eMvm3uNe7D3avDHNm4zXbF99m54346KNOvRp0jJ39rBgh6I4wQ1SUal/vUYcs+nDI3DQ==} + '@vercel/backends@7.0.0': + resolution: {integrity: sha512-he5zmmtKzzaoizZhPXHxd9bOge3pOL90uz33b9IzmGnImWBlkSOPtGQr5r+NX2ad8HS5HWBdUchwAEcnxYz66w==} peerDependencies: - typescript: ^4.0.0 || ^5.0.0 + '@vercel/build-utils': 14.9.0 - '@vercel/blob@2.3.0': - resolution: {integrity: sha512-oYWiJbWRQ7gz9Mj0X/NHFJ3OcLMOBzq/2b3j6zeNrQmtFo6dHwU8FAwNpxVIYddVMd+g8eqEi7iRueYx8FtM0Q==} + '@vercel/blob@2.8.0': + resolution: {integrity: sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==} engines: {node: '>=20.0.0'} - '@vercel/build-utils@13.10.0': - resolution: {integrity: sha512-i+fF1EvEzZhrifP1qUYklTmrUTmndPfsvWGLsv9TaDm5WqX8VOp8irUAhOwc5gc5RHEOs4Ox0GtiPhJ7oZ59cw==} + '@vercel/build-utils@14.9.0': + resolution: {integrity: sha512-czxOQSyZgFYZoD72gRSkRSokGghDyh5pMBPiuy0bjq2I4t7vjiENF1FN0NI/em5S/ITh2hKN1kzXHFwQy9jg6g==} - '@vercel/cervel@0.0.38': - resolution: {integrity: sha512-1/802XtFsfOmZH7hNTQqlMv/8rSNwTOkJPY0uU1CgXk7yYMG9nq2uOTF1eumx+0TwMc8cO9X9azOi35lGP++yw==} + '@vercel/cervel@0.1.59': + resolution: {integrity: sha512-zFpD1AWHher/SYpwJAZTnw9ncf3qVdPWGG0fTUz0dTlO7nkdV67zXLej46y+PrbML1LtHXMqBQRauM9Y8uH+wg==} hasBin: true - peerDependencies: - typescript: ^4.0.0 || ^5.0.0 - '@vercel/detect-agent@1.2.1': - resolution: {integrity: sha512-U/BJCltQSTFTHwaiCQQTQG3GonTbRoEewjV+OU2mMjcHLAoPOh6CP1SXA2XNmqiqI3c82nkRNJ7piZ14RqmTXw==} + '@vercel/cli-auth@0.3.5': + resolution: {integrity: sha512-DSkTWamJrhgCUrymOSCWysbiVeLsvPINhoKVTw+mypoyIJxKQxDgQaafvZ0OYGS2qg8wVUY30HgDugzaEdwo6Q==} + + '@vercel/cli-config@0.2.4': + resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/container@7.0.0': + resolution: {integrity: sha512-VhQAJv8j6/ufedWYAbwjJ94p3R0MfNYiJmamr68NeFVGJlv6sFNm1SJb1Rzl+6udK44BcQp3M64qKFO4ARNLFA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/detect-agent@1.2.5': + resolution: {integrity: sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==} engines: {node: '>=14'} - '@vercel/elysia@0.1.53': - resolution: {integrity: sha512-SdQP06NbODpTOBc6NRV9y/5vpV4wfBZFpWZiJ8oUj5F9POPR7tz+FempLi/YfTv5OuYiA76K3mugNFFEOtpyrg==} + '@vercel/elysia@7.0.0': + resolution: {integrity: sha512-EZgu9HXQVf1af7sElg3tws+0TTT/k1DgPyDftJnXfk1L3W8M31pqbBY3a6I1hcnWxNaNceF2VtSWMPNrmsTOVQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/error-utils@2.0.3': - resolution: {integrity: sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==} + '@vercel/error-utils@2.2.1': + resolution: {integrity: sha512-9DhP8jP7raLML4hGsBemxX5fXuQnu5xxMV+HjGygGbzEmVK/+KyJ3QP2Cw7PdF0uXdb9N0Qa4c3tRGH34ZX6vw==} - '@vercel/express@0.1.63': - resolution: {integrity: sha512-hUQk+rVo+26NH8DAqXrxiC9j2ajvMlz6iwY+KQYFwHxh9mI+NmeBRD8ELJdl54Z5tEeec8BQ/9giKmbu+NpzkA==} + '@vercel/express@7.0.0': + resolution: {integrity: sha512-dhGOtQk3HJXQBeFyfiDP0/nIWqwzrvN02rr/tF8zEk45Z6xqLlSkvH47lVPlvVQdoPy93MOlwKA7/gYapi1chg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/fastify@0.1.56': - resolution: {integrity: sha512-4LXxtieB+UVPLeGgw0q48g4PX+uquBIJPVlgs1ABySI5X4IVJXm3KTAABuaZ0mJ/yAryIc4FXD3pYzU7hSCfQg==} + '@vercel/fastify@7.0.0': + resolution: {integrity: sha512-tcB2pd0DdQls884nS70bkZgdrYRFceST1zZO/073o1iJG0VVoFcIPuDdePGlIIetegGJ+S68Wc47gr4TU/UQcQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 '@vercel/fun@1.3.0': resolution: {integrity: sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg==} engines: {node: '>= 18'} - '@vercel/gatsby-plugin-vercel-analytics@1.0.11': - resolution: {integrity: sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==} + '@vercel/gatsby-plugin-vercel-analytics@1.0.12': + resolution: {integrity: sha512-Ejlhwxr7EBYJxtwYnlh6Vm6A2dPsUSPxMdYrfv0koZkgAzVwo7cG4aDQiy7iASMaGzwuI/PAnwlY2sJBF5b4OA==} - '@vercel/gatsby-plugin-vercel-builder@2.1.4': - resolution: {integrity: sha512-PONQs8DAc/P/tWGHGluDWE4aD0WfjDkod9sr8ksJJUpvkhanPpTQAFj8CTCbnr9Tu/9FWa1ZdwB4Q2+pXCWTEA==} + '@vercel/gatsby-plugin-vercel-builder@2.2.52': + resolution: {integrity: sha512-PYl9TBsYsP0a7qB4kgrF0a5YBUt7caiZwrxd4dl/uPdWHZlogTrnw2Cf2GS/kgij4rsew5jhc9xMnE4eHvQ1zg==} - '@vercel/go@3.4.6': - resolution: {integrity: sha512-ig91qRY+f4lLXWoRvnhEvu5DcT7LXtALBo/jJqxvlyOsHRBP4mdAvUgg9sygu2NOeMSV/cy249yJqQniP32HWw==} + '@vercel/go@10.0.0': + resolution: {integrity: sha512-G2tHOrb64snuckAdfq+OjMqE8fKIB4rq3FpbmaQ1vjvnyK/PqyWnvX9gCoIigThM49P0RkGQVp76DCt1RFGh4Q==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/h3@0.1.62': - resolution: {integrity: sha512-0gzAyyf3rZf6ZG0ZTE33TczpX5ioR3dF8p4i78wVH+xEmaQ4u1o8yvVMK49CcMoQ1qjJVdpwQfI41BAfKQk7ig==} + '@vercel/h3@7.0.0': + resolution: {integrity: sha512-GYhMAK/XgDAQmgXSaiokz2kjc8QeE4azNg9aajcAm2q16Yd+t4a+VZutHppfk4I5SaLf5sKAGhGB8hkCHkbTPA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/hono@0.2.56': - resolution: {integrity: sha512-fB7zOx9B+eYzUDBmwZHqKn3kVQ2XYUjTdMQb1+FxWmSKr5T/uIRuX8ayWnD1lT5Bmrt5534nB6wVfZRljmWWzA==} + '@vercel/hono@7.0.0': + resolution: {integrity: sha512-Bn+illFuXukoI0l2z9Y75IS0c8mnLwy0zf5D0AIC2vegjPy5PnoYZ8c7mnEh88On/2ahMR3KEfocWnDssbpGHQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/hydrogen@1.3.6': - resolution: {integrity: sha512-Ec8dKEjGIM4BfThcRLtQs5zaJ4+iJbgLZwkytwi7Blk8VrK6W2F1dtLDmVQYZdVnQcnmHmTx8mxUuMkfP06Mnw==} + '@vercel/hydrogen@8.0.0': + resolution: {integrity: sha512-Z2CKkTKn2SsqD5ftXXizPJ2HCmUoWvvEL9RnSd/eup1IPpOMK42MjIGyh+aPiiwzOwcPmoANPCmMufqrUOjHzw==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/koa@0.1.36': - resolution: {integrity: sha512-ht1w0Qjrb9uMGbfz7aFabs1CCOG24XH5tLYN+sb4sYKHdioKusbpa4u9O76WrAwJXtnyHc48hJE4eyb6lCTndQ==} + '@vercel/koa@7.0.0': + resolution: {integrity: sha512-UgfQVMc2+hjfoMrGmAFhHmGIf1uDwNL+3Y2B+Ad9Gn4D2wz2gn2Pl5ywy23VOmfSD/aTerbLigTWKT+y9fb9Tg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/nestjs@0.2.57': - resolution: {integrity: sha512-qXE4Z0BZTj4KEpDJtZp0/2x1aOWf97tUHoatCG+ovTRLIdtSLhyZUFDcoqv/T/WZ4MbO24JqX2sqLmuiTUxIow==} + '@vercel/nestjs@7.0.0': + resolution: {integrity: sha512-suD7cuigAQlLA/RLAly8234gXXgIC/XG7AGBa1h2Y4vuIwGP43EBHZ4IuojdN6YCKwbD4/2eUMRa/qBs9OpsNQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/next@4.16.3': - resolution: {integrity: sha512-yVLrMxMI+Taq47C94lWVUf981WerJ+COrJbgltHcMY8HDdXdgthYe06+na3dni31AL6BYShS8VLpE54QsAdM9Q==} + '@vercel/next@11.0.0': + resolution: {integrity: sha512-eTaJA+6iKLfhRwOl44mAuNj35jnuA5uExpDvy8vN+oWW0F5Ycjc2yoLHffbUO+9x3SWmo9CV5hpkGKfSSrJakg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/nft@1.5.0': - resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} + '@vercel/nft@1.10.0': + resolution: {integrity: sha512-iLOW4fcsgkipfOh2Bw3wB38YDfxTlxr7+j4uFeui2OswkNT28jIitS/aMce7tS0mef1YPQ8zLIDYr3a0aahNrA==} engines: {node: '>=20'} hasBin: true - '@vercel/node@5.6.20': - resolution: {integrity: sha512-n9ZMFzuRauAQNhwVvmsS/prG0We7RyDP2j/tPQoxcnOq5vP1DK6A+r7dG46sRTytgxVBgVJtU3486RAOvhOgcg==} + '@vercel/node@12.0.0': + resolution: {integrity: sha512-F3tbqSdN1Nap1zeZcdfScatAVFUrLLYXNBsHf9wutOMyOjuW7M32NEEp1eXhjHXdrIg9SyChooqvJPmt3iepSw==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/prepare-flags-definitions@0.2.1': - resolution: {integrity: sha512-ouXTsqn7I9xZ1KKezgvn/w3tZeQHL/tc52j9GHiOYi6kT8xgdbT8s2x8C9BQr44iceX0hfhtZwk9q7NuI2Tqbw==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} - '@vercel/python-analysis@0.11.0': - resolution: {integrity: sha512-gsoj+nscmNm0xDh+tRhECRhit2VlAVaD7jc9h93sN6rDEBDxPo7eLEgIJFzVDaAItxERZ9Od2IK/04fB9vFy+g==} + '@vercel/oidc@3.8.5': + resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} + engines: {node: '>= 20'} - '@vercel/python@6.28.0': - resolution: {integrity: sha512-/Ley7HPz/AXhxO7unK5+4hEtsMUxXa02SJ8Tiaz//nEtRQMUcVREIyAUkOOfecjiCpg2hMHSVyCtABFjhzAbgA==} + '@vercel/prepare-flags-definitions@0.3.0': + resolution: {integrity: sha512-/0nuDFwYje0nqZnVKSd2VfJy2wOPQwbkas1qO1JQgtb0sLl+EeSCW4O9hrvq55pN50PNlAZ/APSeWHIAT9ZGHg==} - '@vercel/redwood@2.4.12': - resolution: {integrity: sha512-8kJ7eEerI4iMpKVRxQCsnxiIwRVsWtyirEbYb4erCqqsJymTu/xrjhsfAUuzeP8qkuYGP82MJVK+hpKlFtsjGw==} + '@vercel/python-analysis@0.14.0': + resolution: {integrity: sha512-qyVxbaU14gAi/AsaR8syZLukUsOp69Jkm1xn80rZPy4k9+zRUTIfqs0AOk7L8wwBxDDB6LLjcBUAmafhbJQnUQ==} - '@vercel/remix-builder@5.7.2': - resolution: {integrity: sha512-bfStsDBQramYbWugelfyp1szTuDOLQ+ZELvgA9cpYc4FucgCrDy/bpvMMvsjiDACB9PoDzLrG//ZBgsic9yAhg==} + '@vercel/python@13.0.0': + resolution: {integrity: sha512-KUqi9G5jx26m10kbpzgd8q2jGlijgX4aawH5aD2J8T7pu4q0dZGTw0DlczdrSFnEDYd8yM3zghGjXjtvcrKeaQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/ruby@2.3.2': - resolution: {integrity: sha512-okIgMmPEePyDR9TZYaKM4oftcxVHM5Dbdl7V/tIdh3lq8MGLi7HR5vvQglmZUwZOeovE6MVtezxl960EOzeIiQ==} + '@vercel/redwood@9.0.0': + resolution: {integrity: sha512-y4YdEsqjGVHFcLTPKZWppTvcgXbq9edxxXl+KBEJvtJpqY7s4ZjFd+BzF8YF6KTC7x18rYOiugBSrbyLyctRtQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/rust@1.0.5': - resolution: {integrity: sha512-Y03g59nv1uT6Da+PvB/50WqJSHlaFZ9MSkG00R82dUcTySslMbQdOeaXymZtabrmU8zQYhWDb1/CwBki8sWnaQ==} + '@vercel/remix-builder@12.0.0': + resolution: {integrity: sha512-SQqmOQSQn44Migiu/xglGZ2EjlE4YfEEi8GpOHfN2iVKZhGqyeA5spTzVuxWAHjxeYbaOarHW7qM/giaFc0e2A==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/static-build@2.9.4': - resolution: {integrity: sha512-TqObjKTlr9nGkOzAUFweshKdbqOFKj8hS1xE499A7wYqlZWcORfcn+Yqe9ifXi628KA9+K+sLPaXJN/D4krr7A==} + '@vercel/ruby@9.0.0': + resolution: {integrity: sha512-kTmJZWkZpSEcK1t0FuM1Py1PTLDTgGbmhwXt1B1Tv/ZpJU2UfLK9rxzNUZemy2jdxSRtFWL+D/Ur0j1sF1W7Hg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/static-config@3.2.0': - resolution: {integrity: sha512-UpOEIgWxWx0M+mDe1IMdHS6JuWM/L5nNIJ4ixX8v9JgBAejymo88OkgnmfLCNMem0Wd+b5vcQPWLdZybCndlsA==} + '@vercel/rust@8.0.0': + resolution: {integrity: sha512-Ut16g8BZkwedJ8NN+LlPZPbiy4sC4efYYl2f440A6dg7obW5t4xjt9M1wTkooIgT0w2/j/FVfDdmQNJGfpPCgA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/sandbox@3.1.0': + resolution: {integrity: sha512-z124E4rsmNpwGtTnLImiYzEXk972bWniQyhlvkEt35UtwKTdfBAFXcNG2OvqYqwzAsdQaP3B7teQ2pqA9B5Viw==} + + '@vercel/static-build@9.0.0': + resolution: {integrity: sha512-JrYaWxWmq83vQofB669VTp9egqoJN4wn3JgduvgCj3mNsdRj7R2wvyMPdGJWNm6t5Jsq3YMikik9VVmxZpctRQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/static-config@3.4.3': + resolution: {integrity: sha512-BY1sL8rNJvIm3TK/8TQ+Q6jIx7NpO5xckQzv7s6c1ypHvRnTl+vf6rmgY5WFXmoYDeGm3tMy4jiy/5M/5jGr/g==} + + '@vercel/vc-native-darwin-arm64@59.11.1': + resolution: {integrity: sha512-Lpxu0C2h3HThwfa7hrQXtzvy0uMAg2XAgMAyS0xwe10LQ5+OiX3nNFQCb4sGKhCBHvtiHv14iSCZRgQo1HtNFQ==} + cpu: [arm64] + os: [darwin] + + '@vercel/vc-native-darwin-x64@59.11.1': + resolution: {integrity: sha512-aAmQaFTC37ZNFWwf+WZ+zSzCnbKXR6UkQC77Zx2sbc/yGsjraBzf44Nza9fL1lLfVsIYs+wixmLKhvig1AQ06A==} + cpu: [x64] + os: [darwin] + + '@vercel/vc-native-linux-arm64@59.11.1': + resolution: {integrity: sha512-npU8GcrkwqyOotX2txj5TRIC9gof7uFr1Lp5fhzlw7qLoZNTzP/uNu//erNsSi4/LjIGBAD0YAnWVv6XkvlT1Q==} + cpu: [arm64] + os: [linux] + + '@vercel/vc-native-linux-x64@59.11.1': + resolution: {integrity: sha512-CV0nod07WyVceW9KucckRZSi4AVuQYz/T+lKjSmnNOFXz9TPi6i0X1R8ObcDfSzi0o6kgVhUGZS2+DXDiUTHqQ==} + cpu: [x64] + os: [linux] + + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -1963,10 +2250,6 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1992,9 +2275,6 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2007,6 +2287,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2017,15 +2305,19 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + baseline-browser-mapping@2.11.1: resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.3.1: - resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} - engines: {node: '>=10.0.0'} - bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2132,10 +2424,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2160,9 +2448,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-es@2.0.1: - resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2173,10 +2458,6 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2225,18 +2506,14 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -2311,6 +2588,9 @@ packages: es-module-lexer@1.4.1: resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} + es-module-lexer@1.5.0: + resolution: {integrity: sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2355,13 +2635,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - - eslint-config-next@16.2.1: - resolution: {integrity: sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==} + eslint-config-next@16.3.4: + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2464,11 +2739,6 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2519,6 +2789,9 @@ packages: events-intercept@2.0.0: resolution: {integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + execa@3.2.0: resolution: {integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==} engines: {node: ^8.12.0 || >=9.7.0} @@ -2533,6 +2806,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -2588,10 +2864,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - framer-motion@12.42.2: resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} peerDependencies: @@ -2756,6 +3028,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2775,10 +3051,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -2880,10 +3152,6 @@ packages: resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==} engines: {node: '>= 0.6'} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2926,10 +3194,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2978,6 +3242,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -3064,6 +3333,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3081,15 +3354,14 @@ packages: jose@5.9.6: resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -3121,9 +3393,15 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3226,6 +3504,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -3237,10 +3518,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@1.26.0: resolution: {integrity: sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==} peerDependencies: @@ -3445,12 +3722,8 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -3505,8 +3778,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3518,18 +3791,14 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - netmask@2.1.1: - resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} - engines: {node: '>= 0.4.0'} - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.1: - resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3649,6 +3918,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3661,6 +3934,10 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + oxc-parser@0.121.0: + resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-transform@0.111.0: resolution: {integrity: sha512-oa5KKSDNLHZGaiqIGAbCWXeN9IJUAz9MElWcQX90epDxdKc9Hrt/BsLj3K4gDqfAYa5dwdH+ZCFJG9hR74fiGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3677,14 +3954,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3751,12 +4020,12 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.22: - resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3776,13 +4045,6 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3957,6 +4219,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandbox@4.1.0: + resolution: {integrity: sha512-kzDiAyvrGHGdrQ/7mT6Md18K9OUVgZW/KUKO/wBJ/gHouDh6oJPWcGWfOV5i7CSep2map3Pl7vV9gszm3Cvu7Q==} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3992,9 +4258,14 @@ packages: setprototypeof@1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -4031,30 +4302,14 @@ packages: resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} engines: {node: '>=14'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.5.2: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.9: - resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -4062,8 +4317,8 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - srvx@0.8.9: - resolution: {integrity: sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==} + srvx@0.11.16: + resolution: {integrity: sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==} engines: {node: '>=20.16.0'} hasBin: true @@ -4088,6 +4343,9 @@ packages: resolution: {integrity: sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==} deprecated: Deprecated. Use node:stream/promises and node:stream/consumers instead. + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4163,14 +4421,15 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.21: - resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} - engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} @@ -4283,14 +4542,18 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@5.28.4: - resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4355,8 +4618,12 @@ packages: '@types/react': optional: true - vercel@50.37.0: - resolution: {integrity: sha512-GVeZ/5vw1dZXw2S3aEVmo05BbfspKc+gb3+h6hTz0Dm1IW3lCpAI7A/FXor8XDMHy64PhOjYCwpEu6Qnr3Cz+Q==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + vercel@59.11.1: + resolution: {integrity: sha512-vGmxxo6NcqfMcHMJ2rtTsOLTdYjb/Jp49eAMh/zpTvvDS7BCqQdCLpeMTxxZ+5yezQWDKvYstzueRicEuNfubg==} engines: {node: '>= 18'} hasBin: true @@ -4409,6 +4676,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xdg-app-paths@5.1.0: resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} engines: {node: '>=6'} @@ -4456,6 +4735,9 @@ packages: zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -4591,7 +4873,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true @@ -4762,6 +5044,11 @@ snapshots: eslint: 9.39.5(jiti@2.7.0) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.2': @@ -4788,7 +5075,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4849,110 +5136,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iarna/toml@2.2.5': {} - '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@isaacs/balanced-match@4.0.1': {} + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true - '@isaacs/brace-expansion@5.0.1': + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': dependencies: - '@isaacs/balanced-match': 4.0.1 + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true '@isaacs/fs-minipass@4.0.1': dependencies: @@ -4985,7 +5274,7 @@ snapshots: node-fetch: 2.7.0 nopt: 8.1.0 semver: 7.8.5 - tar: 7.5.21 + tar: 7.5.22 transitivePeerDependencies: - encoding - supports-color @@ -5020,6 +5309,57 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/keyring-darwin-arm64@1.2.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.2.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring@1.2.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.2.0 + '@napi-rs/keyring-darwin-x64': 1.2.0 + '@napi-rs/keyring-freebsd-x64': 1.2.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.2.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.2.0 + '@napi-rs/keyring-linux-arm64-musl': 1.2.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-musl': 1.2.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.2.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 + '@napi-rs/keyring-win32-x64-msvc': 1.2.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5027,41 +5367,44 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.2.1': {} + '@next/env@16.3.4': {} - '@next/eslint-plugin-next@16.2.1': + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint - '@next/swc-darwin-arm64@16.2.1': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.2.1': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.1': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.2.1': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.2.1': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.2.1': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.1': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.2.1': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5080,8 +5423,75 @@ snapshots: '@orama/orama@3.1.18': {} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.121.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + optional: true + '@oxc-project/types@0.110.0': {} + '@oxc-project/types@0.121.0': {} + '@oxc-transform/binding-android-arm-eabi@0.111.0': optional: true @@ -5130,9 +5540,9 @@ snapshots: '@oxc-transform/binding-openharmony-arm64@0.111.0': optional: true - '@oxc-transform/binding-wasm32-wasi@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@oxc-transform/binding-wasm32-wasi@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5532,9 +5942,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5600,7 +6010,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -5670,12 +6080,10 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.22 + postcss: 8.5.26 tailwindcss: 4.3.3 - '@tootallnate/once@2.0.0': {} - - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/once@2.0.1': {} '@ts-morph/common@0.11.1': dependencies: @@ -5802,7 +6210,7 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5898,19 +6306,21 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/backends@0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': + '@vercel/backends@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/build-utils': 13.10.0 - '@vercel/nft': 1.5.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) execa: 3.2.0 fs-extra: 11.1.0 - oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + get-port: 5.1.1 + oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) path-to-regexp: 8.3.0 resolve.exports: 2.0.3 - rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) - srvx: 0.8.9 + rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + srvx: 0.11.16 + ts-morph: 12.0.0 tsx: 4.21.0 - typescript: 5.9.3 zod: 3.22.4 transitivePeerDependencies: - '@emnapi/core' @@ -5919,22 +6329,59 @@ snapshots: - rollup - supports-color - '@vercel/blob@2.3.0': + '@vercel/blob@2.8.0': dependencies: + '@vercel/oidc': 3.8.5 async-retry: 1.3.3 is-buffer: 2.0.5 is-node-process: 1.2.0 throttleit: 2.1.0 - undici: 6.27.0 + undici: 6.28.0 - '@vercel/build-utils@13.10.0': + '@vercel/build-utils@14.9.0': dependencies: - '@vercel/python-analysis': 0.11.0 + cjs-module-lexer: 1.2.3 + es-module-lexer: 1.5.0 - '@vercel/cervel@0.0.38(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': + '@vercel/cervel@0.1.59(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/backends': 0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - typescript: 5.9.3 + '@vercel/backends': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@vercel/build-utils' + - encoding + - rollup + - supports-color + + '@vercel/cli-auth@0.3.5': + dependencies: + '@napi-rs/keyring': 1.2.0 + '@vercel/cli-config': 0.2.4 + async-listen: 3.0.0 + open: 8.4.0 + zod: 4.1.11 + + '@vercel/cli-config@0.2.4': + dependencies: + xdg-app-paths: 5.1.0 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/container@7.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/detect-agent@1.2.5': {} + + '@vercel/elysia@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5942,25 +6389,15 @@ snapshots: - rollup - supports-color - '@vercel/detect-agent@1.2.1': {} + '@vercel/error-utils@2.2.1': {} - '@vercel/elysia@0.1.53': + '@vercel/express@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/error-utils@2.0.3': {} - - '@vercel/express@0.1.63(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': - dependencies: - '@vercel/cervel': 0.0.38(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/nft': 1.5.0 - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/cervel': 0.1.59(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/nft': 1.10.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 path-to-regexp: 8.3.0 ts-morph: 12.0.0 @@ -5971,20 +6408,22 @@ snapshots: - encoding - rollup - supports-color - - typescript - '@vercel/fastify@0.1.56': + '@vercel/fastify@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color '@vercel/fun@1.3.0': dependencies: - '@tootallnate/once': 2.0.0 + '@tootallnate/once': 2.0.1 async-listen: 1.2.0 debug: 4.3.4 generic-pool: 3.4.2 @@ -5996,7 +6435,7 @@ snapshots: semver: 7.5.4 stat-mode: 0.3.0 stream-to-promise: 2.2.0 - tar: 7.5.7 + tar: 7.5.22 tinyexec: 0.3.2 tree-kill: 1.2.2 uid-promise: 1.0.0 @@ -6006,75 +6445,94 @@ snapshots: - encoding - supports-color - '@vercel/gatsby-plugin-vercel-analytics@1.0.11': + '@vercel/gatsby-plugin-vercel-analytics@1.0.12': dependencies: web-vitals: 0.2.4 - '@vercel/gatsby-plugin-vercel-builder@2.1.4': + '@vercel/gatsby-plugin-vercel-builder@2.2.52': dependencies: '@sinclair/typebox': 0.25.24 - '@vercel/build-utils': 13.10.0 + '@vercel/build-utils': 14.9.0 esbuild: 0.27.0 etag: 1.8.1 fs-extra: 11.1.0 - '@vercel/go@3.4.6': {} - - '@vercel/h3@0.1.62': + '@vercel/go@10.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + + '@vercel/h3@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/hono@0.2.56': + '@vercel/hono@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/nft': 1.5.0 - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 path-to-regexp: 8.3.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/hydrogen@1.3.6': + '@vercel/hydrogen@8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@vercel/koa@0.1.36': + '@vercel/koa@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - encoding + - rollup + - supports-color + + '@vercel/nestjs@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - encoding + - rollup + - supports-color + + '@vercel/next@11.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 transitivePeerDependencies: - encoding - rollup - supports-color - '@vercel/nestjs@0.2.57': - dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/next@4.16.3': - dependencies: - '@vercel/nft': 1.5.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/nft@1.5.0': + '@vercel/nft@1.10.0': dependencies: '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.4.0 @@ -6093,16 +6551,16 @@ snapshots: - rollup - supports-color - '@vercel/node@5.6.20': + '@vercel/node@12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: '@edge-runtime/node-utils': 2.3.0 '@edge-runtime/primitives': 4.1.0 '@edge-runtime/vm': 3.2.0 '@types/node': 20.11.0 - '@vercel/build-utils': 13.10.0 - '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/error-utils': 2.2.1 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) async-listen: 3.0.0 cjs-module-lexer: 1.2.3 edge-runtime: 2.5.9 @@ -6116,71 +6574,132 @@ snapshots: ts-morph: 12.0.0 tsx: 4.21.0 typescript: 5.9.3 - undici: 5.28.4 + undici: 5.29.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/prepare-flags-definitions@0.2.1': {} + '@vercel/oidc@3.2.0': {} - '@vercel/python-analysis@0.11.0': + '@vercel/oidc@3.8.5': + dependencies: + '@vercel/cli-config': 0.2.4 + '@vercel/cli-exec': 1.0.1 + jose: 5.9.6 + + '@vercel/prepare-flags-definitions@0.3.0': {} + + '@vercel/python-analysis@0.14.0': dependencies: '@bytecodealliance/preview2-shim': 0.17.6 '@renovatebot/pep440': 4.2.1 fs-extra: 11.1.1 - js-yaml: 4.1.1 - minimatch: 10.1.1 + js-yaml: 4.3.2 + minimatch: 10.2.6 smol-toml: 1.5.2 zod: 3.22.4 - '@vercel/python@6.28.0': + '@vercel/python@13.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/python-analysis': 0.11.0 + '@vercel/build-utils': 14.9.0 + '@vercel/python-analysis': 0.14.0 - '@vercel/redwood@2.4.12': + '@vercel/redwood@9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) semver: 6.3.1 ts-morph: 12.0.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/remix-builder@5.7.2': + '@vercel/remix-builder@12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/error-utils': 2.2.1 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) path-to-regexp: 6.1.0 path-to-regexp-updated: path-to-regexp@6.3.0 ts-morph: 12.0.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/ruby@2.3.2': {} - - '@vercel/rust@1.0.5': + '@vercel/ruby@9.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@iarna/toml': 2.2.5 + '@vercel/build-utils': 14.9.0 + + '@vercel/rust@8.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 execa: 5.1.1 + get-port: 5.1.1 + smol-toml: 1.5.2 - '@vercel/static-build@2.9.4': + '@vercel/sandbox@3.1.0': dependencies: - '@vercel/gatsby-plugin-vercel-analytics': 1.0.11 - '@vercel/gatsby-plugin-vercel-builder': 2.1.4 - '@vercel/static-config': 3.2.0 - ts-morph: 12.0.0 + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jose: 6.2.3 + jsonlines: 0.1.1 + lru-cache: 10.4.3 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.29.0 + xdg-app-paths: 5.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a - '@vercel/static-config@3.2.0': + '@vercel/static-build@9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/gatsby-plugin-vercel-analytics': 1.0.12 + '@vercel/gatsby-plugin-vercel-builder': 2.2.52 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@vercel/static-config@3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: ajv: 8.6.3 json-schema-to-ts: 1.6.4 + oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@vercel/vc-native-darwin-arm64@59.11.1': + optional: true + + '@vercel/vc-native-darwin-x64@59.11.1': + optional: true + + '@vercel/vc-native-linux-arm64@59.11.1': + optional: true + + '@vercel/vc-native-linux-x64@59.11.1': + optional: true + + '@workflow/serde@4.1.0-beta.2': {} abbrev@3.0.1: {} @@ -6295,10 +6814,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - astring@1.9.0: {} async-function@1.0.0: {} @@ -6315,8 +6830,6 @@ snapshots: async-sema@3.1.1: {} - asynckit@0.4.0: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -6325,15 +6838,17 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.1: {} + bail@2.0.2: {} balanced-match@1.0.2: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.1: {} + bare-events@2.9.2: {} - basic-ftp@5.3.1: {} + baseline-browser-mapping@2.11.1: {} bindings@1.5.0: dependencies: @@ -6432,10 +6947,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} compute-scroll-into-view@3.1.1: {} @@ -6450,8 +6961,6 @@ snapshots: convert-source-map@2.0.0: {} - cookie-es@2.0.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6462,8 +6971,6 @@ snapshots: damerau-levenshtein@1.0.8: {} - data-uri-to-buffer@6.0.2: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6506,20 +7013,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - - delayed-stream@1.0.0: {} - depd@1.1.2: {} dequal@2.0.3: {} @@ -6662,6 +7163,8 @@ snapshots: es-module-lexer@1.4.1: {} + es-module-lexer@1.5.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6764,17 +7267,9 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: + eslint-config-next@16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - - eslint-config-next@16.2.1(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.2.1 + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) @@ -6965,8 +7460,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7022,6 +7515,12 @@ snapshots: events-intercept@2.0.0: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + execa@3.2.0: dependencies: cross-spawn: 7.0.6 @@ -7051,6 +7550,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7109,14 +7610,6 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - framer-motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.42.2 @@ -7141,7 +7634,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -7168,22 +7661,22 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.17 lucide-react: 1.26.0(react@19.2.4) - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) - js-yaml: 4.3.0 + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 @@ -7199,12 +7692,12 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -7220,7 +7713,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) lucide-react: 1.26.0(react@19.2.4) motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7234,7 +7727,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -7277,6 +7770,8 @@ snapshots: get-nonce@1.0.1: {} + get-port@5.1.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -7298,14 +7793,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: - dependencies: - basic-ftp: 5.3.1 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7318,7 +7805,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 @@ -7481,13 +7968,6 @@ snapshots: statuses: 1.5.0 toidentifier: 1.0.0 - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -7524,8 +8004,6 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 - ip-address@10.2.0: {} - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7581,6 +8059,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@2.2.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -7661,6 +8141,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7678,13 +8162,11 @@ snapshots: jose@5.9.6: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - js-yaml@4.3.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -7709,12 +8191,16 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 + jsonlines@0.1.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7798,6 +8284,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -7808,8 +8296,6 @@ snapshots: dependencies: yallist: 4.0.0 - lru-cache@7.18.3: {} - lucide-react@1.26.0(react@19.2.4): dependencies: react: 19.2.4 @@ -8276,11 +8762,7 @@ snapshots: mimic-fn@2.1.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@10.2.5: + minimatch@10.2.6: dependencies: brace-expansion: 5.0.8 @@ -8320,41 +8802,40 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - netmask@2.1.1: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.1 - '@swc/helpers': 0.5.15 + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.1 caniuse-lite: 1.0.30001806 - postcss: 8.4.31 + postcss: 8.5.23 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.4(@types/node@20.19.43) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-exports-info@1.6.2: @@ -8452,6 +8933,12 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@8.4.0: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8470,7 +8957,35 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-transform@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2): + oxc-parser@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): + dependencies: + '@oxc-project/types': 0.121.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.121.0 + '@oxc-parser/binding-android-arm64': 0.121.0 + '@oxc-parser/binding-darwin-arm64': 0.121.0 + '@oxc-parser/binding-darwin-x64': 0.121.0 + '@oxc-parser/binding-freebsd-x64': 0.121.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.121.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.121.0 + '@oxc-parser/binding-linux-arm64-musl': 0.121.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.121.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-musl': 0.121.0 + '@oxc-parser/binding-openharmony-arm64': 0.121.0 + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 + '@oxc-parser/binding-win32-x64-msvc': 0.121.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-transform@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): optionalDependencies: '@oxc-transform/binding-android-arm-eabi': 0.111.0 '@oxc-transform/binding-android-arm64': 0.111.0 @@ -8488,7 +9003,7 @@ snapshots: '@oxc-transform/binding-linux-x64-gnu': 0.111.0 '@oxc-transform/binding-linux-x64-musl': 0.111.0 '@oxc-transform/binding-openharmony-arm64': 0.111.0 - '@oxc-transform/binding-wasm32-wasi': 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@oxc-transform/binding-wasm32-wasi': 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@oxc-transform/binding-win32-arm64-msvc': 0.111.0 '@oxc-transform/binding-win32-ia32-msvc': 0.111.0 '@oxc-transform/binding-win32-x64-msvc': 0.111.0 @@ -8506,24 +9021,6 @@ snapshots: dependencies: p-limit: 3.1.0 - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.1.1 - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8577,15 +9074,15 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.4.31: + postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.22: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8605,21 +9102,6 @@ snapshots: property-information@7.2.0: {} - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8822,7 +9304,7 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2): + rolldown@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.110.0 '@rolldown/pluginutils': 1.0.0-rc.1 @@ -8837,7 +9319,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.1 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.1 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.1 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 transitivePeerDependencies: @@ -8869,6 +9351,20 @@ snapshots: safer-buffer@2.1.2: {} + sandbox@4.1.0: + dependencies: + '@vercel/sandbox': 3.1.0 + async-retry: 1.3.3 + debug: 4.4.3 + ws: 8.21.3 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -8907,36 +9403,38 @@ snapshots: setprototypeof@1.1.1: {} - sharp@0.34.5: + sharp@0.35.4(@types/node@20.19.43): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 20.19.43 optional: true shebang-command@2.0.0: @@ -8988,35 +9486,15 @@ snapshots: signal-exit@4.0.2: {} - smart-buffer@4.2.0: {} - smol-toml@1.5.2: {} - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.9 - transitivePeerDependencies: - - supports-color - - socks@2.8.9: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true - source-map@0.7.6: {} space-separated-tokens@2.0.2: {} - srvx@0.8.9: - dependencies: - cookie-es: 2.0.1 + srvx@0.11.16: {} stable-hash@0.0.5: {} @@ -9039,6 +9517,15 @@ snapshots: end-of-stream: 1.1.0 stream-to-array: 2.3.0 + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -9128,7 +9615,16 @@ snapshots: tapable@2.3.3: {} - tar@7.5.21: + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -9136,13 +9632,11 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tar@7.5.7: + text-decoder@1.2.7: dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a throttleit@2.1.0: {} @@ -9265,11 +9759,13 @@ snapshots: undici-types@6.21.0: {} - undici@5.28.4: + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 - undici@6.27.0: {} + undici@6.28.0: {} + + undici@7.29.0: {} unified@11.0.5: dependencies: @@ -9369,44 +9865,62 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - vercel@50.37.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3): + uuid@14.0.1: {} + + vercel@59.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: - '@vercel/backends': 0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/blob': 2.3.0 - '@vercel/build-utils': 13.10.0 - '@vercel/detect-agent': 1.2.1 - '@vercel/elysia': 0.1.53 - '@vercel/express': 0.1.63(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/fastify': 0.1.56 + '@vercel/backends': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/blob': 2.8.0 + '@vercel/build-utils': 14.9.0 + '@vercel/cli-auth': 0.3.5 + '@vercel/cli-config': 0.2.4 + '@vercel/container': 7.0.0(@vercel/build-utils@14.9.0) + '@vercel/detect-agent': 1.2.5 + '@vercel/elysia': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/express': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/fastify': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/fun': 1.3.0 - '@vercel/go': 3.4.6 - '@vercel/h3': 0.1.62 - '@vercel/hono': 0.2.56 - '@vercel/hydrogen': 1.3.6 - '@vercel/koa': 0.1.36 - '@vercel/nestjs': 0.2.57 - '@vercel/next': 4.16.3 - '@vercel/node': 5.6.20 - '@vercel/prepare-flags-definitions': 0.2.1 - '@vercel/python': 6.28.0 - '@vercel/redwood': 2.4.12 - '@vercel/remix-builder': 5.7.2 - '@vercel/ruby': 2.3.2 - '@vercel/rust': 1.0.5 - '@vercel/static-build': 2.9.4 + '@vercel/go': 10.0.0(@vercel/build-utils@14.9.0) + '@vercel/h3': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/hono': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/hydrogen': 8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/koa': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/nestjs': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/next': 11.0.0(@vercel/build-utils@14.9.0) + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/prepare-flags-definitions': 0.3.0 + '@vercel/python': 13.0.0(@vercel/build-utils@14.9.0) + '@vercel/python-analysis': 0.14.0 + '@vercel/redwood': 9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/remix-builder': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/ruby': 9.0.0(@vercel/build-utils@14.9.0) + '@vercel/rust': 8.0.0(@vercel/build-utils@14.9.0) + '@vercel/static-build': 9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) chokidar: 4.0.0 esbuild: 0.27.0 - form-data: 4.0.6 jose: 5.9.6 + jsonc-parser: 3.3.1 luxon: 3.7.2 - proxy-agent: 6.4.0 + sandbox: 4.1.0 + smol-toml: 1.5.2 + undici: 5.29.0 + uuid: 14.0.1 + zod: 4.1.11 + optionalDependencies: + '@vercel/vc-native-darwin-arm64': 59.11.1 + '@vercel/vc-native-darwin-x64': 59.11.1 + '@vercel/vc-native-linux-arm64': 59.11.1 + '@vercel/vc-native-linux-x64': 59.11.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' + - bare-abort-controller + - bufferutil - encoding + - react-native-b4a - rollup - supports-color - - typescript + - utf-8-validate vfile-location@5.0.3: dependencies: @@ -9483,6 +9997,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + xdg-app-paths@5.1.0: dependencies: xdg-portable: 7.3.0 @@ -9521,6 +10037,8 @@ snapshots: zod@3.22.4: {} + zod@4.1.11: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6473659419d..60ccee97d2f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -93,7 +93,7 @@ importers: version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-router: specifier: ^55.0.18 - version: 55.0.18(2f99795f9796def5cdb1516a493dc117) + version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) expo-secure-store: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -178,7 +178,7 @@ importers: version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) happy-dom: specifier: ^20.11.8 version: 20.11.8 @@ -199,10 +199,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.16 - version: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -2897,6 +2897,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/react-native@0.73.0': resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. @@ -3356,8 +3359,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true @@ -3398,8 +3401,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3448,8 +3451,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -3941,8 +3944,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.352: - resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -5228,6 +5231,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -5492,8 +5499,8 @@ packages: resolution: {integrity: sha512-tnn0J5wzgTgTx2OJy3Cwr1y79bJz4eNgFQd+2HENOs5Vz6QOMnt05z7J+BedIo9wIbpEa0iN9U1nerxyvMRE9g==} engines: {node: '>=20.19.4'} - metro-babel-transformer@0.84.4: - resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-cache-key@0.83.7: @@ -5504,8 +5511,8 @@ packages: resolution: {integrity: sha512-I38PtcjT4crS5HY9UQ8i6z8S7tJ2WewtPGr/OwS6FcLKfy5T/1hlTaFw+wozUZkEtNpR6Gc0oJuvuKCbSoSN5A==} engines: {node: '>=20.19.4'} - metro-cache-key@0.84.4: - resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-cache@0.83.7: @@ -5516,8 +5523,8 @@ packages: resolution: {integrity: sha512-aogMG5WbKzW5000otNjYrS9hIoORzkCI1faPJK+vxQLaf2BorJKBBFe/jl2Tfsi1mZUglS2EBuBt8B3JB7MYDQ==} engines: {node: '>=20.19.4'} - metro-cache@0.84.4: - resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-config@0.83.7: @@ -5528,8 +5535,8 @@ packages: resolution: {integrity: sha512-crNbNy+/B4tCne2+HjUshwvC57gNBQj+V9fFSy3lHH2RlKcLHib3Mil/SfTX8Pfh2fCY5pPuSu2OKa7YiadB+Q==} engines: {node: '>=20.19.4'} - metro-config@0.84.4: - resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-core@0.83.7: @@ -5540,8 +5547,8 @@ packages: resolution: {integrity: sha512-NTyOUOQaQKvQgJG9VI2ymN6KTM7gHEqkVFhkPc9bK4BsHSTz/EaXuFBXtC+wtwINLeH9tbusL/jfsSmdEmuU7A==} engines: {node: '>=20.19.4'} - metro-core@0.84.4: - resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-file-map@0.83.7: @@ -5552,8 +5559,8 @@ packages: resolution: {integrity: sha512-+W++EUuzEXIfWQEFTWQMVThzhWbnJL4gRNJ9WSHzIAM5pT7gsprUxo9+2hrfirURm/TLvrKwhq37oCECJcDSyQ==} engines: {node: '>=20.19.4'} - metro-file-map@0.84.4: - resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-minify-terser@0.83.7: @@ -5564,8 +5571,8 @@ packages: resolution: {integrity: sha512-7tU0J5/c7LZaZJwTlOb1xq0NepTFvGzRxigDhZOq4jSE6g0BRHmBqe7XvLH3WcRiJNGy+eshcZr34RpMjb6mmg==} engines: {node: '>=20.19.4'} - metro-minify-terser@0.84.4: - resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-resolver@0.83.7: @@ -5576,8 +5583,8 @@ packages: resolution: {integrity: sha512-piU0NVTI9i37YztDVF5rtn9uxP3NebVT0xZM9NKJ9z0jbigrctUQksi3NoYIeJMvL6Wn2dgAehpcmmnfn+gUwA==} engines: {node: '>=20.19.4'} - metro-resolver@0.84.4: - resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-runtime@0.83.7: @@ -5588,8 +5595,8 @@ packages: resolution: {integrity: sha512-f7FfeM0pamq8vrvs8aO9KvIUabbUKe0WkHFpLt6Q9yIIIsORqNFwlgJeHGraOFPU7Cxqj5yLXkJu5bT1uwDXvw==} engines: {node: '>=20.19.4'} - metro-runtime@0.84.4: - resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-source-map@0.83.7: @@ -5600,8 +5607,8 @@ packages: resolution: {integrity: sha512-60Uor7bM+KsVewLkLCcZfkPFCbqjPDdoSmeBuT3+ye+ac80BuLWbbR8DpyxWpUqQeZPdaAT5ZqFgDIs8BNEcVA==} engines: {node: '>=20.19.4'} - metro-source-map@0.84.4: - resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-symbolicate@0.83.7: @@ -5614,8 +5621,8 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - metro-symbolicate@0.84.4: - resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true @@ -5627,8 +5634,8 @@ packages: resolution: {integrity: sha512-9JRPkvi+m0QH2Y/w5RjCF9mHqOUNFpMFLDDLhKUjhcKESh8Wm3HKDdHXHVldTN1lTToCIabv81nF0zyJzKEJ5g==} engines: {node: '>=20.19.4'} - metro-transform-plugins@0.84.4: - resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-transform-worker@0.83.7: @@ -5639,8 +5646,8 @@ packages: resolution: {integrity: sha512-Pa2hOfhUmWpI/dmkhsLq8uGyFHK2opoEK7j/YCiRtqNSe0YzzxYguXTNgg8AMiuZfc9LPcB1AhGENS10O5wcIw==} engines: {node: '>=20.19.4'} - metro-transform-worker@0.84.4: - resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro@0.83.7: @@ -5653,8 +5660,8 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - metro@0.84.4: - resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true @@ -5763,8 +5770,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -5795,8 +5803,8 @@ packages: resolution: {integrity: sha512-pk7el+eTOzfSKMAY4QBiiwKzegXn633JQj13y+pW5E5IdS+yV2CfDJ9Hf20K/LKy8nCrP9dAmd7XOk52OJxifA==} engines: {node: '>=20.19.4'} - ob1@0.84.4: - resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} object-assign@4.1.1: @@ -6677,6 +6685,11 @@ packages: engines: {node: '>=10'} hasBin: true + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -6862,8 +6875,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7301,7 +7314,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -7309,7 +7322,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -8694,7 +8707,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8773,7 +8786,7 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117) + expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@expo/dom-webview' @@ -8985,7 +8998,7 @@ snapshots: '@expo/json-file': 10.0.16 '@expo/metro': 55.1.2 '@expo/spawn-async': 1.8.0 - browserslist: 4.28.2 + browserslist: 4.28.8 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 @@ -9116,7 +9129,7 @@ snapshots: react: 19.2.8 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117) + expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color @@ -9201,14 +9214,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -9281,7 +9294,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -9975,8 +9988,8 @@ snapshots: dependencies: '@react-native/js-polyfills': 0.85.2 '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7) - metro-config: 0.84.4 - metro-runtime: 0.84.4 + metro-config: 0.84.5 + metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - bufferutil @@ -10126,7 +10139,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -10136,7 +10149,7 @@ snapshots: react-test-renderer: 19.2.8(react@19.2.8) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) '@tootallnate/once@2.0.1': {} @@ -10345,6 +10358,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/node@26.4.0': + dependencies: + undici-types: 8.3.0 + '@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)': dependencies: react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -10525,13 +10542,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10934,7 +10951,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.27: {} + baseline-browser-mapping@2.11.20: {} better-opn@3.0.2: dependencies: @@ -10972,13 +10989,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.352 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -11024,7 +11041,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -11174,7 +11191,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 cose-base@1.0.3: dependencies: @@ -11184,13 +11201,13 @@ snapshots: dependencies: layout-base: 2.0.1 - create-jest@29.7.0(@types/node@26.1.2): + create-jest@29.7.0(@types/node@26.4.0): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11554,7 +11571,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.352: {} + electron-to-chromium@1.5.416: {} emittery@0.13.1: {} @@ -12169,7 +12186,7 @@ snapshots: expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -12177,7 +12194,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -12185,11 +12202,11 @@ snapshots: commander: 12.1.0 eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12252,7 +12269,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.18(2f99795f9796def5cdb1516a493dc117): + expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e): dependencies: '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12289,7 +12306,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12950,7 +12967,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -12970,16 +12987,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@26.1.2): + jest-cli@29.7.0(@types/node@26.4.0): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@26.1.2) + create-jest: 29.7.0(@types/node@26.4.0) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -12989,7 +13006,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@26.1.2): + jest-config@29.7.0(@types/node@26.4.0): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -13014,7 +13031,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -13069,7 +13086,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 @@ -13080,7 +13097,7 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) json5: 2.2.3 lodash: 4.18.1 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -13184,7 +13201,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -13212,7 +13229,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -13279,11 +13296,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.2)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -13308,12 +13325,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@26.1.2): + jest@29.7.0(@types/node@26.4.0): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@26.1.2) + jest-cli: 29.7.0(@types/node@26.4.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -13333,6 +13350,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsc-safe-url@0.2.4: {} jsdom@20.0.3: @@ -13604,12 +13625,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.84.4: + metro-babel-transformer@0.84.5: dependencies: '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 - metro-cache-key: 0.84.4 + metro-cache-key: 0.84.5 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color @@ -13622,7 +13643,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-cache-key@0.84.4: + metro-cache-key@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -13644,12 +13665,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-cache@0.84.4: + metro-cache@0.84.5: dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 https-proxy-agent: 7.0.6 - metro-core: 0.84.4 + metro-core: 0.84.5 transitivePeerDependencies: - supports-color @@ -13683,15 +13704,15 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.84.4: + metro-config@0.84.5: dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.4 - metro-cache: 0.84.4 - metro-core: 0.84.4 - metro-runtime: 0.84.4 + metro: 0.84.5 + metro-cache: 0.84.5 + metro-core: 0.84.5 + metro-runtime: 0.84.5 yaml: 2.9.0 transitivePeerDependencies: - bufferutil @@ -13710,11 +13731,11 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.83.8 - metro-core@0.84.4: + metro-core@0.84.5: dependencies: flow-enums-runtime: 0.0.6 lodash.throttle: 4.1.1 - metro-resolver: 0.84.4 + metro-resolver: 0.84.5 metro-file-map@0.83.7: dependencies: @@ -13744,7 +13765,7 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.84.4: + metro-file-map@0.84.5: dependencies: debug: 4.4.3 fb-watchman: 2.0.2 @@ -13768,10 +13789,10 @@ snapshots: flow-enums-runtime: 0.0.6 terser: 5.49.1 - metro-minify-terser@0.84.4: + metro-minify-terser@0.84.5: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.49.1 + terser: 5.51.2 metro-resolver@0.83.7: dependencies: @@ -13781,7 +13802,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-resolver@0.84.4: + metro-resolver@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -13795,7 +13816,7 @@ snapshots: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-runtime@0.84.4: + metro-runtime@0.84.5: dependencies: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 @@ -13828,15 +13849,15 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.84.4: + metro-source-map@0.84.5: dependencies: '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-symbolicate: 0.84.4 + metro-symbolicate: 0.84.5 nullthrows: 1.1.1 - ob1: 0.84.4 + ob1: 0.84.5 source-map: 0.5.7 vlq: 1.0.1 transitivePeerDependencies: @@ -13864,11 +13885,11 @@ snapshots: transitivePeerDependencies: - supports-color - metro-symbolicate@0.84.4: + metro-symbolicate@0.84.5: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.84.4 + metro-source-map: 0.84.5 nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 @@ -13897,7 +13918,7 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-plugins@0.84.4: + metro-transform-plugins@0.84.5: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 @@ -13948,20 +13969,20 @@ snapshots: - supports-color - utf-8-validate - metro-transform-worker@0.84.4: + metro-transform-worker@0.84.5: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.84.4 - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-minify-terser: 0.84.4 - metro-source-map: 0.84.4 - metro-transform-plugins: 0.84.4 + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5 + metro-transform-plugins: 0.84.5 nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil @@ -14059,7 +14080,7 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.4: + metro@0.84.5: dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -14076,23 +14097,22 @@ snapshots: flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 hermes-parser: 0.35.0 - image-size: 1.2.1 invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-config: 0.84.4 - metro-core: 0.84.4 - metro-file-map: 0.84.4 - metro-resolver: 0.84.4 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4 - metro-symbolicate: 0.84.4 - metro-transform-plugins: 0.84.4 - metro-transform-worker: 0.84.4 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14175,7 +14195,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.38: {} + node-releases@2.0.54: {} normalize-path@3.0.0: {} @@ -14206,7 +14226,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - ob1@0.84.4: + ob1@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -15212,6 +15232,13 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.6 @@ -15267,11 +15294,11 @@ snapshots: ts-dedent@2.3.0: {} - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -15381,9 +15408,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -15442,7 +15469,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -15450,17 +15477,17 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 esbuild: 0.25.4 fsevents: 2.3.3 - terser: 5.49.1 + terser: 5.51.2 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15477,10 +15504,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 happy-dom: 20.11.8 jsdom: 20.0.3 transitivePeerDependencies: diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 25902305f41..19a9cfc07ba 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -1,24 +1,31 @@ import { readFileSync } from 'node:fs' -const SOURCE_FILES = [ - './terminal-webview-html.ts', - './terminal-webview-html/document-shell.ts', - './terminal-webview-html/runtime-state-and-text-scaling.ts', - './terminal-webview-html/fit-scale-and-write-queue.ts', - './terminal-webview-html/terminal-init-and-write.ts', - './terminal-webview-html/host-message-router.ts', - './terminal-webview-html/selection-state-and-eviction.ts', - './terminal-webview-html/term-observers-and-mode-mirroring.ts', - './terminal-webview-html/mouse-report-and-scroll-routing.ts', - './terminal-webview-html/smooth-scroll-and-cell-geometry.ts', - './terminal-webview-html/selection-overlay.ts', - './terminal-webview-html/surface-touch-gestures.ts', - './terminal-webview-html/message-bridge-and-document-close.ts' -] as const +const COMPOSER_FILE = './terminal-webview-html.ts' +const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm +const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm -/** Reads the TypeScript source that assembles the in-WebView document. */ +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +/** + * Reads the TypeScript source that assembles the in-WebView document. + * + * Why: the slice list is derived from the composer's own imports rather than duplicated, so a + * new slice cannot join the emitted document while staying invisible to the tests that search + * this source. The count cross-check catches an import shape the regex cannot see. + */ export function readTerminalWebViewHtmlSource(): string { - return SOURCE_FILES.map((relativePath) => - readFileSync(new URL(relativePath, import.meta.url), 'utf8') - ).join('\n') + const composer = readSource(COMPOSER_FILE) + const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`) + const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length + if (composedCount === 0) { + throw new Error('no composed WebView document slices found') + } + if (slices.length !== composedCount) { + throw new Error( + `WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})` + ) + } + return [composer, ...slices.map(readSource)].join('\n') } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 40b7a2db22c..17fadd4d26c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,6 +1,8 @@ import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' -import { TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE } from './terminal-webview-html/fit-scale-and-write-queue' +import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale' +import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan' +import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue' import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' @@ -19,7 +21,9 @@ export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_SHELL, TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, - TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE, + TERMINAL_HTML_FIT_SCALE, + TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN, + TERMINAL_HTML_WRITE_QUEUE, TERMINAL_HTML_INIT_AND_WRITE, TERMINAL_HTML_HOST_MESSAGE_ROUTER, TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, diff --git a/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts b/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts deleted file mode 100644 index 074185d43a5..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fit-scale-and-write-queue.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' - -// Also carries the DECSET mouse-mode scanner: emitted-document order pins it between these two concerns. -export const TERMINAL_HTML_FIT_SCALE_AND_WRITE_QUEUE = `${TERMINAL_WEBVIEW_THEME_JS} - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - - function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - - function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..6f0685df87e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts @@ -0,0 +1,52 @@ +export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) { + if (typeof data !== 'string') return false; + var on = data.lastIndexOf(ESC + '[?1049h'); + var off = data.lastIndexOf(ESC + '[?1049l'); + return on !== -1 && on > off; + } + + function normalizeInitialData(data) { + if (!isAltScreenActive(data)) return data; + var on = data.lastIndexOf(ESC + '[?1049h'); + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data; + } + + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts new file mode 100644 index 00000000000..b756bcb550c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts @@ -0,0 +1,130 @@ +import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' + +// Opens with the injected theme block: it lands at this point in the emitted document. +export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS} + + function getCellHeight() { + if (!term || !term._core) return 15; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15; + } + return 15; + } + + // Why: clamp pan so the terminal content always covers the viewport + // when zoomed in. When content is smaller than viewport in a + // dimension, pin to top-left (no floating in the middle). + function clampPan() { + if (!term || !term.element) return; + var ts = getTotalScale(); + var cw = term.element.scrollWidth * ts; + var ch = term.element.scrollHeight * ts; + var vpW = window.innerWidth; + var vpH = window.innerHeight; + if (cw > vpW) { + panX = Math.min(0, Math.max(vpW - cw, panX)); + } else { + panX = 0; + } + if (ch > vpH) { + panY = Math.min(0, Math.max(vpH - ch, panY)); + } else { + panY = 0; + } + } + + // Why: intentional no-op. Mobile replays a live PTY snapshot then applies + // live cursor-relative chunks from that same PTY; resizing only the WebView + // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap. Kept as a no-op so its call sites stay legible. + function adjustRowsForViewport() {} + + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; + var fitRetryToken = 0; + function applyFitScale(reason) { + if (!term || !term.element) return; + var token = ++fitRetryToken; + var attempts = 0; + var lastScrollWidth = -1; + function attempt() { + if (token !== fitRetryToken) return; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); + return; + } + requestAnimationFrame(attempt); + } + requestAnimationFrame(attempt); + } + + function commitFitScale(reason, attempts, gate) { + if (!term || !term.element) return; + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (currentScale >= 0.95) currentScale = 1; + userScale = 1; + panX = 0; + panY = 0; + smoothScrollOffsetY = 0; + updateTransform(); + adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }); + } + repositionOverlay(); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts new file mode 100644 index 00000000000..ae8ed85297f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/write-queue.ts @@ -0,0 +1,110 @@ +// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to +// other concerns, but emitted-document order pins them inside this queue. +export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function isStatusDotPresentationSelector(value) { + return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; + } + + function endsWithStatusDotPresentationSequence(data) { + var i = data.length - 1; + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; + return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; + } + + // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. + function normalizeStatusDotPresentation(data) { + if (typeof data !== 'string' || data.length === 0) return data; + if (statusDotPendingSelector) { + statusDotPendingSelector = false; + var strippedPendingSelectors = false; + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); + strippedPendingSelectors = data.length === 0; + if (strippedPendingSelectors) { + statusDotPendingSelector = true; + return ''; + } + } + var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); + statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); + return normalized; + } + + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); + } + + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + + function pumpWrites(gen) { + if (!ready || !term || writesDraining || gen !== terminalGeneration) return; + var next = nextQueuedWrite(); + if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); + var callbacks = afterDrainCallbacks; + afterDrainCallbacks = []; + for (var i = 0; i < callbacks.length; i++) callbacks[i](); + return; + } + writesDraining = true; + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + term.write(next, function() { + if (gen !== terminalGeneration) return; + writesDraining = false; + pumpWrites(gen); + }); + } + + function afterWritesDrained(callback) { + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); + } + +` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts new file mode 100644 index 00000000000..f8bfa4bd134 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +// Why: every other WebView test exercises one slice of the document, so an edit to an +// uncovered region ships silently. A diff here means the emitted WebView source changed — +// update these values only when that change is deliberate, and only after checking the +// document still runs. Refactors that merely move slice boundaries must leave them alone. +const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608' +const EXPECTED_LENGTH = 729776 + +describe('terminal WebView payload', () => { + it('composes the expected document', () => { + expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH) + expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256) + }) +}) diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index 471a53be740..bef276f918d 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -59,7 +59,8 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { // fail with EADDRINUSE; the full scenario restarts on the captured port // because the client keeps reconnecting to its original URL. function startServer(port = 0): Promise { - const wss = new WebSocketServer({ port }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port }) wss.on('connection', (ws: ServerSocket) => { let sharedKey: Uint8Array | null = null let authenticated = false diff --git a/package.json b/package.json index d93b33280a2..5bfc6aaeac9 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 d7170b0e626..f0f8ec467ce 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: @@ -159,7 +159,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 @@ -12284,7 +12284,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-install.sh b/resources/linux/packaging/after-install.sh index a5b598e2bf2..06e77ecbaaa 100755 --- a/resources/linux/packaging/after-install.sh +++ b/resources/linux/packaging/after-install.sh @@ -11,6 +11,19 @@ set -e link="/usr/bin/orca-ide" +is_owned_link() { + [ -L "$link" ] || return 1 + local link_target candidate candidate_target + link_target="$(readlink -f -- "$link" 2>/dev/null || true)" + for candidate in /opt/Orca/resources/bin/orca-ide /opt/orca-ide/resources/bin/orca-ide /opt/orca/resources/bin/orca-ide; do + candidate_target="$(readlink -f -- "$candidate" 2>/dev/null || true)" + if [ -n "$candidate_target" ] && [ "$link_target" = "$candidate_target" ]; then + return 0 + fi + done + return 1 +} + for dir in /opt/Orca /opt/orca-ide /opt/orca; do sandbox="$dir/chrome-sandbox" if [ -f "$sandbox" ]; then @@ -22,8 +35,8 @@ for dir in /opt/Orca /opt/orca-ide /opt/orca; do shim="$dir/resources/bin/orca-ide" if [ -x "$shim" ]; then # Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide. - if [ ! -e "$link" ] || [ -L "$link" ]; then - ln -sf "$shim" "$link" + if { [ ! -e "$link" ] && [ ! -L "$link" ]; } || is_owned_link; then + ln -sfn -- "$shim" "$link" fi break fi 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-awake-service.ts b/src/main/agent-awake-service.ts index 29e866d27f2..b79612e2b9c 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -117,6 +117,11 @@ export class AgentAwakeService { } } + /** Agents this runtime has seen working recently, independent of the awake setting. */ + getWorkingAgentCount(): number { + return this.getEligibleRunningStatusCount() + } + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { this.statusListeners.add(listener) return () => this.statusListeners.delete(listener) 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/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/appimage-runtime-identity.test.ts b/src/main/appimage-runtime-identity.test.ts new file mode 100644 index 00000000000..f9a7319ee1c --- /dev/null +++ b/src/main/appimage-runtime-identity.test.ts @@ -0,0 +1,240 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from './appimage-runtime-identity' + +const fixtureRoots: string[] = [] + +function appImageHeader(machine: number): Buffer { + const header = Buffer.alloc(64) + header.set([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01]) + header.set([0x41, 0x49, 0x02], 8) + header.writeUInt16LE(machine, 18) + return header +} + +function createFixture(appDirName = '.mount_Orca123', machine = 0x3e) { + const root = mkdtempSync(join(tmpdir(), 'orca-appimage-identity-')) + const appImagePath = join(root, 'Applications', 'Orca.AppImage') + const appDirPath = join(root, appDirName) + const execPath = join(appDirPath, 'orca-ide') + const resourcesPath = join(appDirPath, 'resources') + const packageTypePath = join(resourcesPath, 'package-type') + const packageMarkerPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + fixtureRoots.push(root) + mkdirSync(dirname(appImagePath), { recursive: true }) + mkdirSync(dirname(packageMarkerPath), { recursive: true }) + writeFileSync(appImagePath, appImageHeader(machine), { mode: 0o755 }) + writeFileSync(join(appDirPath, 'AppRun'), '#!/bin/sh\n', { mode: 0o755 }) + writeFileSync(execPath, appImageHeader(machine), { mode: 0o755 }) + writeFileSync( + packageMarkerPath, + JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true }) + ) + return { + root, + appImagePath, + appDirPath, + execPath, + resourcesPath, + packageTypePath, + packageMarkerPath, + identity: { + platform: 'linux' as const, + environment: { APPIMAGE: appImagePath, APPDIR: appDirPath }, + execPath, + resourcesPath + } + } +} + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe.skipIf(process.platform === 'win32')('resolveAppImageRuntimeIdentity', () => { + it.each([ + ['x64', 0x3e, '.mount_Orca123'], + ['ARM64 extract-and-run', 0xb7, 'appimage_extracted_123'] + ])('accepts a complete %s AppImage runtime', (_architecture, machine, appDirName) => { + const fixture = createFixture(appDirName, machine) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toEqual({ + appImagePath: fixture.appImagePath + }) + }) + + it('accepts an AppImage moved independently of its runtime directory', () => { + const fixture = createFixture() + const movedPath = join(fixture.root, 'Moved Apps', 'Orca current.AppImage') + mkdirSync(dirname(movedPath), { recursive: true }) + renameSync(fixture.appImagePath, movedPath) + fixture.identity.environment.APPIMAGE = movedPath + expect(resolveAppImageRuntimeIdentity(fixture.identity)?.appImagePath).toBe(movedPath) + }) + + it('accepts the exact AppImage package-type marker', () => { + const fixture = createFixture() + rmSync(fixture.packageMarkerPath) + writeFileSync(fixture.packageTypePath, 'AppImage') + expect(resolveAppImageRuntimeIdentity(fixture.identity)).not.toBeNull() + }) + + it.each([ + ['APPIMAGE', undefined], + ['APPIMAGE', 'relative/Orca.AppImage'], + ['APPDIR', undefined], + ['APPDIR', 'relative/mount'], + ['APPIMAGE', '/tmp/Orca\0.AppImage'] + ] as const)('rejects an unusable %s value', (key, value) => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + environment: { ...fixture.identity.environment, [key]: value } + }) + ).toBeNull() + }) + + it.each([ + ['an ordinary executable', (path: string) => writeFileSync(path, '#!/bin/sh\n')], + [ + 'bad ELF magic', + (path: string) => { + const header = appImageHeader(0x3e) + header[0] = 0 + writeFileSync(path, header) + } + ], + [ + 'bad AppImage type magic', + (path: string) => { + const header = appImageHeader(0x3e) + header[10] = 1 + writeFileSync(path, header) + } + ], + ['a non-executable file', (path: string) => chmodSync(path, 0o644)], + [ + 'a directory', + (path: string) => { + rmSync(path) + mkdirSync(path) + } + ] + ])('rejects APPIMAGE pointing to %s', (_case, mutate) => { + const fixture = createFixture() + mutate(fixture.appImagePath) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it.each([ + [ + 'AppRun', + (fixture: ReturnType) => rmSync(join(fixture.appDirPath, 'AppRun')) + ], + [ + 'an executable AppRun', + (fixture: ReturnType) => + chmodSync(join(fixture.appDirPath, 'AppRun'), 0o644) + ], + [ + 'the Orca package marker', + (fixture: ReturnType) => rmSync(fixture.packageMarkerPath) + ] + ])('rejects a runtime missing %s', (_case, mutate) => { + const fixture = createFixture() + mutate(fixture) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects forged AppImage variables around an ordinary packaged layout', () => { + const fixture = createFixture() + rmSync(join(fixture.appDirPath, 'AppRun')) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects a package marker for a different application', () => { + const fixture = createFixture() + writeFileSync( + fixture.packageMarkerPath, + JSON.stringify({ name: 'foreign-compiled-output', type: 'commonjs' }) + ) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects an inexact package-type marker without the Orca fallback', () => { + const fixture = createFixture() + rmSync(fixture.packageMarkerPath) + writeFileSync(fixture.packageTypePath, 'appimage') + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects payload evidence that resolves outside APPDIR', () => { + const fixture = createFixture() + const externalAppRun = join(fixture.root, 'foreign-AppRun') + writeFileSync(externalAppRun, '#!/bin/sh\n', { mode: 0o755 }) + rmSync(join(fixture.appDirPath, 'AppRun')) + symlinkSync(externalAppRun, join(fixture.appDirPath, 'AppRun')) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects a package marker that resolves outside APPDIR', () => { + const fixture = createFixture() + const externalMarker = join(fixture.root, 'foreign-package.json') + writeFileSync( + externalMarker, + JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs' }) + ) + rmSync(fixture.packageMarkerPath) + symlinkSync(externalMarker, fixture.packageMarkerPath) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects inherited AppImage variables around a non-AppImage executable', () => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + execPath: join(fixture.root, 'opt', 'Orca', 'orca-ide'), + resourcesPath: join(fixture.root, 'opt', 'Orca', 'resources') + }) + ).toBeNull() + }) + + it('rejects a resources path outside the runtime root', () => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + resourcesPath: `${fixture.appDirPath}-other/resources` + }) + ).toBeNull() + }) + + it('rejects the identity off Linux', () => { + const fixture = createFixture() + expect(resolveAppImageRuntimeIdentity({ ...fixture.identity, platform: 'darwin' })).toBeNull() + }) +}) + +describe('hasAppImagePathEnvironment', () => { + it('requires the AppImage file path before treating the runtime as verifiable', () => { + expect(hasAppImagePathEnvironment({ APPIMAGE: '/tmp/Orca.AppImage' })).toBe(true) + expect(hasAppImagePathEnvironment({ APPDIR: '/tmp/.mount_Orca123' })).toBe(false) + expect(hasAppImagePathEnvironment({ APPIMAGE: '', APPDIR: '/tmp/.mount_Orca123' })).toBe(false) + }) +}) diff --git a/src/main/appimage-runtime-identity.ts b/src/main/appimage-runtime-identity.ts new file mode 100644 index 00000000000..08b2a92dd65 --- /dev/null +++ b/src/main/appimage-runtime-identity.ts @@ -0,0 +1,196 @@ +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, + realpathSync, + statSync, + type Stats +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +const APPIMAGE_HEADER_LENGTH = 11 +const ORCA_PACKAGE_MARKER_MAX_BYTES = 1_024 +const PACKAGE_TYPE_MARKER_MAX_BYTES = 32 + +export type AppImageRuntimeIdentity = { + appImagePath: string +} + +export type AppImageRuntimeIdentityInput = { + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv + execPath?: unknown + resourcesPath?: unknown +} + +function isAbsolutePath(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !value.includes('\0') && isAbsolute(value) +} + +function readExact(fd: number, length: number): Buffer | null { + const bytes = Buffer.alloc(length) + let offset = 0 + while (offset < length) { + const count = readSync(fd, bytes, offset, length - offset, offset) + if (count === 0) { + return null + } + offset += count + } + return bytes +} + +function inspectRegularFile( + filePath: string, + inspect: (fd: number, stats: Stats) => T +): T | null { + let fd: number | undefined + try { + fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK) + const stats = fstatSync(fd) + return stats.isFile() ? inspect(fd, stats) : null + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function hasAppImageHeader(appImagePath: string): boolean { + return ( + inspectRegularFile(appImagePath, (fd, stats) => { + const header = readExact(fd, APPIMAGE_HEADER_LENGTH) + return ( + (stats.mode & 0o111) !== 0 && + header?.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) === true && + header.subarray(8, 11).equals(Buffer.from([0x41, 0x49, 0x02])) + ) + }) === true + ) +} + +function isInside(rootPath: string, candidatePath: string): boolean { + const candidateRelative = relative(rootPath, candidatePath) + return ( + candidateRelative.length > 0 && + candidateRelative !== '..' && + !candidateRelative.startsWith(`..${sep}`) && + !isAbsolute(candidateRelative) + ) +} + +function isExecutablePayloadFile(runtimeRoot: string, filePath: string): boolean { + try { + const canonicalPath = realpathSync(filePath) + const stats = statSync(canonicalPath) + return stats.isFile() && (stats.mode & 0o111) !== 0 && isInside(runtimeRoot, canonicalPath) + } catch { + return false + } +} + +function readPayloadMarker( + runtimeRoot: string, + markerPath: string, + maxBytes: number +): string | null { + try { + const canonicalPath = realpathSync(markerPath) + if (!isInside(runtimeRoot, canonicalPath)) { + return null + } + return inspectRegularFile(canonicalPath, (fd, stats) => + stats.size === 0 || stats.size > maxBytes + ? null + : (readExact(fd, stats.size)?.toString('utf8') ?? null) + ) + } catch { + return null + } +} + +function hasAppImagePackageEvidence(runtimeRoot: string, resourcesPath: string): boolean { + const packageType = readPayloadMarker( + runtimeRoot, + join(resourcesPath, 'package-type'), + PACKAGE_TYPE_MARKER_MAX_BYTES + ) + if (packageType === 'AppImage') { + return true + } + + const content = readPayloadMarker( + runtimeRoot, + join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json'), + ORCA_PACKAGE_MARKER_MAX_BYTES + ) + try { + const marker: unknown = JSON.parse(content ?? '') + return ( + typeof marker === 'object' && + marker !== null && + 'name' in marker && + marker.name === 'orca-compiled-output' && + 'type' in marker && + marker.type === 'commonjs' + ) + } catch { + return false + } +} + +/** Returns whether the process inherited an AppImage file path to validate. */ +export function hasAppImagePathEnvironment(environment: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(environment.APPIMAGE) +} + +export function resolveAppImageRuntimeIdentity( + input: AppImageRuntimeIdentityInput = {} +): AppImageRuntimeIdentity | null { + if ((input.platform ?? process.platform) !== 'linux') { + return null + } + + const environment = input.environment ?? process.env + const appImagePath = environment.APPIMAGE + const appDirPath = environment.APPDIR + const execPath = input.execPath ?? process.execPath + const resourcesPath = input.resourcesPath ?? process.resourcesPath + if ( + !isAbsolutePath(appImagePath) || + !isAbsolutePath(appDirPath) || + !isAbsolutePath(execPath) || + !isAbsolutePath(resourcesPath) + ) { + return null + } + + const runtimeRoot = resolve(appDirPath) + if ( + resolve(dirname(execPath)) !== runtimeRoot || + resolve(resourcesPath) !== resolve(join(runtimeRoot, 'resources')) || + !hasAppImageHeader(appImagePath) + ) { + return null + } + + try { + const realRuntimeRoot = realpathSync(runtimeRoot) + if ( + realpathSync(resourcesPath) !== join(realRuntimeRoot, 'resources') || + !isExecutablePayloadFile(realRuntimeRoot, execPath) || + !isExecutablePayloadFile(realRuntimeRoot, join(runtimeRoot, 'AppRun')) || + !hasAppImagePackageEvidence(realRuntimeRoot, resourcesPath) + ) { + return null + } + } catch { + return null + } + + return { appImagePath } +} diff --git a/src/main/cli/appimage-cache-layout.ts b/src/main/cli/appimage-cache-layout.ts new file mode 100644 index 00000000000..4c692a94a9d --- /dev/null +++ b/src/main/cli/appimage-cache-layout.ts @@ -0,0 +1,34 @@ +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' + +const CACHE_KEY_PATTERN = /^[0-9a-f]{24}$/u + +export function isAppImageCacheKey(value: string): boolean { + return CACHE_KEY_PATTERN.test(value) +} + +export function resolveCachedAppImagePayloadRoot( + cacheRootPath: string, + candidatePath: string, + launcherName = LINUX_CLI_COMMAND_NAME +): string | null { + if (!isAbsolute(candidatePath)) { + return null + } + const resolvedCacheRoot = resolve(cacheRootPath) + const segments = relative(resolvedCacheRoot, resolve(candidatePath)).split(sep) + const [namespaceKey, generationKey, resources, bin, candidateLauncherName] = segments + if ( + segments.length !== 5 || + !namespaceKey || + !generationKey || + !isAppImageCacheKey(namespaceKey) || + !isAppImageCacheKey(generationKey) || + resources !== 'resources' || + bin !== 'bin' || + candidateLauncherName !== launcherName + ) { + return null + } + return join(resolvedCacheRoot, namespaceKey, generationKey) +} diff --git a/src/main/cli/appimage-extracted-root.test.ts b/src/main/cli/appimage-extracted-root.test.ts new file mode 100644 index 00000000000..dbc7c95613b --- /dev/null +++ b/src/main/cli/appimage-extracted-root.test.ts @@ -0,0 +1,376 @@ +import { existsSync } from 'node:fs' +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ensureAppImageExtractedRoot, + getAppImageCacheRootPath, + isAppImageExtractedLauncherPath, + isAppImageExtractionComplete, + isAppImageInstalledLauncherOwnedBySibling, + resolveAppImageCacheKey, + resolveAppImageExtractedRoot +} from './appimage-extracted-root' +import { getAppImageActiveExtractionPath } from './appimage-extraction-pruning' +import { + publishAppImageLauncherEndpoint, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture(): Promise<{ + root: string + appImagePath: string + cacheRootPath: string +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-extract-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +/** Stands in for the AppImage runtime, which writes ./squashfs-root under cwd. */ +async function writePayload(cwd: string, content = ''): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), content, { encoding: 'utf8', mode: 0o755 }) +} + +describe('appimage extracted root', () => { + it('derives the cache root from XDG_CACHE_HOME when set', () => { + const previous = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = '/xdg-cache' + try { + expect(getAppImageCacheRootPath('/home/u')).toBe(join('/xdg-cache', 'orca', 'appimage')) + } finally { + if (previous === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previous + } + } + }) + + it('ignores a relative XDG_CACHE_HOME', () => { + const previous = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = 'relative-cache' + try { + expect(getAppImageCacheRootPath('/home/u')).toBe( + join('/home/u', '.cache', 'orca', 'appimage') + ) + } finally { + if (previous === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previous + } + } + }) + + it('extracts once and reuses the payload on the next call', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extractCount = 0 + const runExtract = async (_path: string, cwd: string): Promise => { + extractCount += 1 + await writePayload(cwd) + } + + const first = await ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + const second = await ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + + expect(extractCount).toBe(1) + expect(second?.stableLauncherPath).toBe(first?.stableLauncherPath) + expect(isAppImageExtractionComplete(first!)).toBe(true) + }) + + // Why: an update replaces the file in place, so stat identity must change the key or the command + // would keep resolving through the previous version's payload. + it('keys the payload on file identity and metadata, not just path', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const before = resolveAppImageCacheKey(appImagePath) + await writeFile(appImagePath, '#!/usr/bin/env bash\n# newer\n', { + encoding: 'utf8', + mode: 0o755 + }) + + expect(resolveAppImageCacheKey(appImagePath)).not.toBe(before) + expect(resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })?.rootPath).toContain( + resolveAppImageCacheKey(appImagePath) as string + ) + }) + + // Why: `chmod +x` is what every AppImage user is told to run, and a backup restore or SELinux + // relabel does the same thing. Re-keying on that cost a full re-extraction of an unchanged payload. + it('does not re-key the payload when only inode metadata changes', async () => { + const { appImagePath } = await makeFixture() + const before = resolveAppImageCacheKey(appImagePath) + + await chmod(appImagePath, 0o700) + expect(resolveAppImageCacheKey(appImagePath)).toBe(before) + + await chmod(appImagePath, 0o755) + expect(resolveAppImageCacheKey(appImagePath)).toBe(before) + }) + + // Why: a crashed extraction must not leave a directory that later reads treat + // as a usable payload — the command would exec a path that does not exist. + it('publishes nothing when extraction fails partway', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + await mkdir(join(cwd, 'squashfs-root'), { recursive: true }) + throw new Error('extraction interrupted') + } + }) + + expect(result).toBeNull() + await expect(readdir(cacheRootPath)).resolves.toEqual([]) + }) + + it('reports failure when the payload has no launcher', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + await mkdir(join(cwd, 'squashfs-root'), { recursive: true }) + } + }) + + expect(result).toBeNull() + }) + + it('retains one retry payload when a foreign stable launcher blocks publication', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + let extractionCount = 0 + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign\n', { mode: 0o755 }) + + const extract = async (_path: string, cwd: string): Promise => { + extractionCount += 1 + await writePayload(cwd) + } + const options = { appImagePath, cacheRootPath, runExtract: extract } + + await expect(ensureAppImageExtractedRoot(options)).resolves.toBeNull() + await expect(ensureAppImageExtractedRoot(options)).resolves.toBeNull() + expect(extractionCount).toBe(1) + expect(existsSync(root.rootPath)).toBe(true) + expect(existsSync(getAppImageActiveExtractionPath(root.rootPath))).toBe(false) + await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') + }) + + it.each(['directory', 'non-executable'] as const)( + 'rejects a %s launcher entry', + async (entryKind) => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + const launcherPath = join(cwd, 'squashfs-root', 'resources', 'bin', 'orca-ide') + if (entryKind === 'directory') { + await mkdir(launcherPath, { recursive: true }) + } else { + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o644 }) + } + } + }) + + expect(result).toBeNull() + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a launcher symlink even when its target is executable', + async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const executable = join(root, 'foreign-launcher') + await writeFile(executable, '#!/usr/bin/env bash\n', { mode: 0o755 }) + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + const launcherPath = join(cwd, 'squashfs-root', 'resources', 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await symlink(executable, launcherPath) + } + }) + + expect(result).toBeNull() + } + ) + + it('replaces an incomplete exact extraction root', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const partialPath = join(root.rootPath, 'partial') + await mkdir(root.rootPath, { recursive: true }) + await writeFile(partialPath, 'interrupted') + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => writePayload(cwd, 'recovered') + }) + + expect(result).toEqual(root) + await expect(readFile(root.payloadLauncherPath, 'utf8')).resolves.toBe('recovered') + expect(existsSync(partialPath)).toBe(false) + }) + + it('preserves the winner when concurrent calls repair an incomplete root', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(root.rootPath, { recursive: true }) + await writeFile(join(root.rootPath, 'partial'), 'interrupted') + let readyCount = 0 + let release!: () => void + const bothReady = new Promise((resolve) => { + release = resolve + }) + const runExtract = async (_path: string, cwd: string): Promise => { + readyCount += 1 + await writePayload(cwd, `winner-${readyCount}`) + if (readyCount === 2) { + release() + } + await bothReady + } + + const results = await Promise.all([ + ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }), + ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + ]) + + expect(results).toEqual([root, root]) + expect(['winner-1', 'winner-2']).toContain(await readFile(root.payloadLauncherPath, 'utf8')) + }) + + it('retries with the current generation when the AppImage changes during extraction', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const initialRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + let extractCount = 0 + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + extractCount += 1 + await writePayload(cwd, `generation-${extractCount}`) + if (extractCount === 1) { + await writeFile(appImagePath, '#!/usr/bin/env bash\n# replaced during extraction\n', { + encoding: 'utf8', + mode: 0o755 + }) + } + } + }) + + const currentRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(extractCount).toBe(2) + expect(result).toEqual(currentRoot) + expect(result?.rootPath).not.toBe(initialRoot.rootPath) + await expect(readFile(currentRoot.payloadLauncherPath, 'utf8')).resolves.toBe('generation-2') + expect(existsSync(initialRoot.rootPath)).toBe(false) + }) + + it('bounds retries when every extraction changes the AppImage generation', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extractCount = 0 + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + extractCount += 1 + await writePayload(cwd) + await writeFile(appImagePath, '#'.repeat(extractCount + 1), { mode: 0o755 }) + } + }) + + expect(result).toBeNull() + expect(extractCount).toBe(2) + }) + + it('returns null for an AppImage that is not there', async () => { + const { root, cacheRootPath } = await makeFixture() + const missing = join(root, 'Absent.AppImage') + + expect(resolveAppImageCacheKey(missing)).toBeNull() + expect(resolveAppImageExtractedRoot({ appImagePath: missing, cacheRootPath })).toBeNull() + }) + + it('recognizes managed launchers across AppImage path namespaces', async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const current = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const previousGeneration = join( + dirname(current.rootPath), + 'a'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + const otherAppImagePath = join(root, 'Other.AppImage') + await writeFile(otherAppImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const other = resolveAppImageExtractedRoot({ + appImagePath: otherAppImagePath, + cacheRootPath + })! + + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, current.stableLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, previousGeneration) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, other.stableLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, other.payloadLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath( + { appImagePath, cacheRootPath }, + join(root, 'foreign', 'resources', 'bin', 'orca-ide') + ) + ).toBe(false) + }) + + it('requires a sibling installed endpoint to target an executable payload', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const siblingLauncher = join( + cacheRootPath, + 'a'.repeat(24), + 'b'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', siblingLauncher) + const options = { appImagePath, cacheRootPath } + + expect(isAppImageInstalledLauncherOwnedBySibling(options)).toBe(false) + + await mkdir(dirname(siblingLauncher), { recursive: true }) + await writeFile(siblingLauncher, '#!/usr/bin/env bash\n', { mode: 0o755 }) + expect(isAppImageInstalledLauncherOwnedBySibling(options)).toBe(true) + }) +}) diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts new file mode 100644 index 00000000000..36d3a8b5493 --- /dev/null +++ b/src/main/cli/appimage-extracted-root.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto' +import { lstatSync, readlinkSync, statSync } from 'node:fs' +import { mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { + APPIMAGE_EXTRACTION_TIMEOUT_MS, + getAppImageActiveExtractionPath, + pruneAppImageExtractedRoots, + trackAppImageExtraction +} from './appimage-extraction-pruning' +import { + isAppImageStableLauncherReady, + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const CACHE_DIR_SEGMENTS = ['orca', 'appimage'] as const +const EXTRACT_OUTPUT_DIR = 'squashfs-root' +const MAX_GENERATION_ATTEMPTS = 2 +const EXTRACTION_STAGING_PREFIX = '.extract-' + +export type AppImageExtractedRoot = { + rootPath: string + payloadLauncherPath: string + stableLauncherPath: string +} + +export type AppImageExtractionOptions = { + appImagePath: string + cacheRootPath?: string + runExtract?: (appImagePath: string, cwd: string) => Promise +} + +export function getAppImageCacheRootPath(homePath = homedir()): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME + const cacheHome = + xdgCacheHome && isAbsolute(xdgCacheHome) ? xdgCacheHome : join(homePath, '.cache') + return join(cacheHome, ...CACHE_DIR_SEGMENTS) +} + +/** + * Identity of the AppImage's *content*, used to key its extracted generation. + * + * Deliberately excludes ctime: it changes on any inode metadata write — `chmod +x` (which every + * AppImage user is told to run), `chown`, an ACL or SELinux relabel, a backup restore — none of + * which alter a byte of the payload. Including it re-keyed the cache on those, costing a full + * ~519 MB re-extraction and a multi-second stall for nothing. An in-place content change moves + * mtime and almost always size; a replacement moves the inode. + */ +export function resolveAppImageCacheKey(appImagePath: string): string | null { + try { + const stats = statSync(appImagePath) + return digest(`${stats.dev}\0${stats.ino}\0${stats.size}\0${stats.mtimeMs}`) + } catch { + return null + } +} + +export function resolveAppImageExtractedRoot( + options: AppImageExtractionOptions +): AppImageExtractedRoot | null { + const cacheKey = resolveAppImageCacheKey(options.appImagePath) + if (!cacheKey) { + return null + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + const rootPath = join(resolveAppImageNamespacePath(options), cacheKey) + return extractedRootAt(rootPath, cacheRootPath) +} + +export function isAppImageExtractedLauncherPath( + options: AppImageExtractionOptions, + candidatePath: string, + launcherName = LINUX_CLI_COMMAND_NAME +): boolean { + if (!isAbsolute(candidatePath)) { + return false + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + if ( + launcherName === LINUX_CLI_COMMAND_NAME && + resolve(candidatePath) === resolveAppImageStableLauncherPath(cacheRootPath) + ) { + return true + } + + return resolveCachedAppImagePayloadRoot(cacheRootPath, candidatePath, launcherName) !== null +} + +export function isAppImageExtractionComplete(root: AppImageExtractedRoot): boolean { + return hasPayloadLauncher(root.rootPath) +} + +export function isAppImageInstalledLauncherCurrent(options: AppImageExtractionOptions): boolean { + const root = resolveAppImageExtractedRoot(options) + const cacheRootPath = resolveAppImageCacheRootPath(options) + if ( + !root || + !isAppImageStableLauncherReady(cacheRootPath) || + !hasPayloadLauncher(root.rootPath) + ) { + return false + } + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + return resolve(dirname(endpointPath), readlinkSync(endpointPath)) === root.payloadLauncherPath + } catch { + return false + } +} + +export function isAppImageInstalledLauncherOwnedBySibling( + options: AppImageExtractionOptions +): boolean { + const cacheRootPath = resolveAppImageCacheRootPath(options) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + const targetPath = resolve(dirname(endpointPath), readlinkSync(endpointPath)) + const targetRoot = resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + return ( + targetRoot !== null && + hasPayloadLauncher(targetRoot) && + dirname(targetRoot) !== resolveAppImageNamespacePath(options) + ) + } catch { + return false + } +} + +export async function ensureAppImageExtractedRoot( + options: AppImageExtractionOptions +): Promise { + for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) { + const root = resolveAppImageExtractedRoot(options) + if (!root) { + return null + } + const complete = + isAppImageExtractionComplete(root) || (await extractAppImageGeneration(options, root)) + if (isCurrentGeneration(options, root)) { + if (!complete || !isAppImageExtractionComplete(root)) { + await cleanFailedEndpointPublication(root) + continue + } + const launcherPath = publishAppImageLauncherEndpoint( + resolveAppImageCacheRootPath(options), + 'installed', + root.payloadLauncherPath + ) + if (launcherPath === root.stableLauncherPath) { + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + return root + } + } + await cleanFailedEndpointPublication(root) + } + return null +} + +async function cleanFailedEndpointPublication(root: AppImageExtractedRoot): Promise { + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + await pruneAppImageExtractedRoots(root.rootPath) +} + +async function extractAppImageGeneration( + options: AppImageExtractionOptions, + root: AppImageExtractedRoot +): Promise { + const namespacePath = dirname(root.rootPath) + await mkdir(namespacePath, { recursive: true }) + const stagingPath = await mkdtemp(join(namespacePath, EXTRACTION_STAGING_PREFIX)) + const stopTracking = trackAppImageExtraction(stagingPath) + try { + await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath) + const extractedPath = join(stagingPath, EXTRACT_OUTPUT_DIR) + if (!hasPayloadLauncher(extractedPath) || !isCurrentGeneration(options, root)) { + return false + } + await writeFile(getAppImageActiveExtractionPath(root.rootPath), '') + return await publishExtractedRoot(extractedPath, root) + } catch { + // A concurrent extractor may have published the same payload first. + return isAppImageExtractionComplete(root) + } finally { + stopTracking() + await removeExtractedAppImagePayload(stagingPath).catch(() => {}) + await rmdir(namespacePath).catch(() => {}) + } +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 24) +} + +export function resolveAppImageNamespacePath(options: AppImageExtractionOptions): string { + return join(resolveAppImageCacheRootPath(options), digest(options.appImagePath)) +} + +export function resolveAppImageCacheRootPath(options: AppImageExtractionOptions): string { + return resolve(options.cacheRootPath ?? getAppImageCacheRootPath()) +} + +function extractedRootAt(rootPath: string, cacheRootPath: string): AppImageExtractedRoot { + return { + rootPath, + payloadLauncherPath: join(rootPath, 'resources', 'bin', LINUX_CLI_COMMAND_NAME), + stableLauncherPath: resolveAppImageStableLauncherPath(cacheRootPath) + } +} + +function isCurrentGeneration( + options: AppImageExtractionOptions, + root: AppImageExtractedRoot +): boolean { + return resolveAppImageExtractedRoot(options)?.rootPath === root.rootPath +} + +async function publishExtractedRoot( + extractedPath: string, + root: AppImageExtractedRoot +): Promise { + if ((await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root)) { + return true + } + + // Claim the destination atomically so a raced complete winner can be restored. + const displacedPath = `${extractedPath}.displaced` + if (!(await renameRoot(root.rootPath, displacedPath))) { + return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) + } + if (hasPayloadLauncher(displacedPath)) { + return (await renameRoot(displacedPath, root.rootPath)) || isAppImageExtractionComplete(root) + } + await removeExtractedAppImagePayload(displacedPath) + return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) +} + +function hasPayloadLauncher(rootPath: string): boolean { + try { + const stats = lstatSync(join(rootPath, 'resources', 'bin', LINUX_CLI_COMMAND_NAME)) + return stats.isFile() && (stats.mode & 0o111) !== 0 + } catch { + return false + } +} + +async function renameRoot(sourcePath: string, destinationPath: string): Promise { + try { + await rename(sourcePath, destinationPath) + return true + } catch { + return false + } +} + +async function runAppImageExtract(appImagePath: string, cwd: string): Promise { + const result = await runProcess({ + program: appImagePath, + args: ['--appimage-extract'], + cwd, + timeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS, + maxOutputBytes: 1024 * 1024, + terminationBarrier: true + }) + if (result.timedOut) { + throw new Error('AppImage extraction timed out.') + } + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `AppImage extraction exited ${result.code ?? 'early'}.`) + } +} diff --git a/src/main/cli/appimage-extraction-pruning.test.ts b/src/main/cli/appimage-extraction-pruning.test.ts new file mode 100644 index 00000000000..4b5745d3619 --- /dev/null +++ b/src/main/cli/appimage-extraction-pruning.test.ts @@ -0,0 +1,221 @@ +import { existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readlink, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const publicationRace = vi.hoisted(() => ({ endpointPath: '', replacementTarget: '' })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, destination: string) => { + if (source === publicationRace.endpointPath && publicationRace.replacementTarget) { + await actual.unlink(source) + await actual.symlink(publicationRace.replacementTarget, source) + publicationRace.replacementTarget = '' + } + return actual.rename(source, destination) + } + } +}) + +import { + ensureAppImageExtractedRoot, + resolveAppImageExtractedRoot +} from './appimage-extracted-root' +import { + getAppImageActiveExtractionPath, + pruneAppImageExtractedRoots, + removeAppImageInstalledPayloads +} from './appimage-extraction-pruning' +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + publicationRace.endpointPath = '' + publicationRace.replacementTarget = '' + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function writePayload(rootPath: string, content = '#!/usr/bin/env bash\n'): Promise { + const launcherPath = join(rootPath, 'resources', 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, content, { mode: 0o755 }) + return launcherPath +} + +async function makeExtractionFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-pruning-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +function cacheKeyApartFrom(...excluded: string[]): string { + return ( + ['a', 'b', 'c'].map((value) => value.repeat(24)).find((key) => !excluded.includes(key)) ?? + 'd'.repeat(24) + ) +} + +describe('AppImage extraction pruning', () => { + it('prunes stale generations without touching sibling namespaces', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const keepRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const keepKey = basename(keepRoot.rootPath) + const staleRoot = join(dirname(keepRoot.rootPath), cacheKeyApartFrom(keepKey)) + const siblingRoot = join( + cacheRootPath, + cacheKeyApartFrom(basename(dirname(keepRoot.rootPath))), + 'd'.repeat(24) + ) + await Promise.all([ + mkdir(keepRoot.rootPath, { recursive: true }), + mkdir(staleRoot, { recursive: true }), + mkdir(siblingRoot, { recursive: true }) + ]) + + await pruneAppImageExtractedRoots(keepRoot.rootPath) + + expect(existsSync(keepRoot.rootPath)).toBe(true) + expect(existsSync(staleRoot)).toBe(false) + expect(existsSync(siblingRoot)).toBe(true) + }) + + it('a sibling installed endpoint does not displace the owner generation', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const ownerRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const siblingRoot = join( + dirname(ownerRoot.rootPath), + cacheKeyApartFrom(basename(ownerRoot.rootPath)) + ) + const [ownerLauncher, siblingLauncher] = await Promise.all([ + writePayload(ownerRoot.rootPath, 'owner'), + writePayload(siblingRoot, 'installed') + ]) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', siblingLauncher) + + await pruneAppImageExtractedRoots(ownerRoot.rootPath) + + await expect(readFile(ownerLauncher, 'utf8')).resolves.toBe('owner') + await expect(readFile(siblingLauncher, 'utf8')).resolves.toBe('installed') + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(siblingLauncher) + }) + + it('preserves an active extraction during pruning', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + let reportStarted!: (stagingPath: string) => void + let releaseExtraction!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + const release = new Promise((resolve) => { + releaseExtraction = resolve + }) + const extraction = ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + reportStarted(cwd) + await release + await writePayload(join(cwd, 'squashfs-root'), '') + } + }) + const stagingPath = await started + + try { + await pruneAppImageExtractedRoots(root.rootPath) + expect(existsSync(stagingPath)).toBe(true) + } finally { + releaseExtraction() + } + await expect(extraction).resolves.toEqual(root) + }) + + it('retains recent cross-process staging and reclaims stale staging', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const namespacePath = dirname(root.rootPath) + const recentStaging = join(namespacePath, '.extract-recent') + const staleStaging = join(namespacePath, '.extract-stale') + await Promise.all([ + mkdir(root.rootPath, { recursive: true }), + mkdir(recentStaging, { recursive: true }), + mkdir(staleStaging, { recursive: true }) + ]) + await utimes(staleStaging, 0, 0) + + await pruneAppImageExtractedRoots(root.rootPath) + + expect(existsSync(recentStaging)).toBe(true) + expect(existsSync(staleStaging)).toBe(false) + }) + + it('preserves a recent active generation marker and reclaims a stale marker', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const keepRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const keepKey = basename(keepRoot.rootPath) + const recentRoot = join(dirname(keepRoot.rootPath), cacheKeyApartFrom(keepKey)) + const staleRoot = join( + dirname(keepRoot.rootPath), + cacheKeyApartFrom(keepKey, basename(recentRoot)) + ) + const recentMarker = getAppImageActiveExtractionPath(recentRoot) + const staleMarker = getAppImageActiveExtractionPath(staleRoot) + await Promise.all([ + mkdir(keepRoot.rootPath, { recursive: true }), + mkdir(recentRoot, { recursive: true }), + mkdir(staleRoot, { recursive: true }) + ]) + await Promise.all([writeFile(recentMarker, ''), writeFile(staleMarker, '')]) + await utimes(staleMarker, 0, 0) + + await pruneAppImageExtractedRoots(keepRoot.rootPath) + + expect(existsSync(recentRoot)).toBe(true) + expect(existsSync(recentMarker)).toBe(true) + expect(existsSync(staleRoot)).toBe(false) + expect(existsSync(staleMarker)).toBe(false) + }) + + it('tolerates a missing cache namespace', async () => { + const { root } = await makeExtractionFixture() + + await expect( + pruneAppImageExtractedRoots(join(root, 'never-made', 'a'.repeat(24), 'b'.repeat(24))) + ).resolves.toBeUndefined() + }) + + it.skipIf(process.platform === 'win32')( + 'restores a sibling endpoint that wins the uninstall rename race', + async () => { + const cacheRootPath = await mkdtemp(join(tmpdir(), 'orca-appimage-pruning-')) + created.push(cacheRootPath) + const ownerNamespace = join(cacheRootPath, 'a'.repeat(24)) + const siblingNamespace = join(cacheRootPath, 'b'.repeat(24)) + const ownerLauncher = await writePayload(join(ownerNamespace, 'c'.repeat(24))) + const siblingLauncher = await writePayload(join(siblingNamespace, 'd'.repeat(24))) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', ownerLauncher) + publicationRace.endpointPath = endpointPath + publicationRace.replacementTarget = siblingLauncher + + await removeAppImageInstalledPayloads(ownerNamespace) + + await expect(readlink(endpointPath)).resolves.toBe(siblingLauncher) + expect(existsSync(ownerNamespace)).toBe(false) + expect(existsSync(siblingLauncher)).toBe(true) + } + ) +}) diff --git a/src/main/cli/appimage-extraction-pruning.ts b/src/main/cli/appimage-extraction-pruning.ts new file mode 100644 index 00000000000..b491982129d --- /dev/null +++ b/src/main/cli/appimage-extraction-pruning.ts @@ -0,0 +1,153 @@ +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import type { Dirent } from 'node:fs' +import { lstat, readlink, readdir, rename, rmdir, symlink, unlink } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' +import { isAppImageCacheKey, resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' +import { + removeAppImageLegacyLiveEndpoint, + resolveAppImageLauncherEndpointPath +} from './appimage-stable-launcher' + +const EXTRACTION_STAGING_PREFIX = '.extract-' +const ACTIVE_EXTRACTION_PREFIX = '.active-' +export const APPIMAGE_EXTRACTION_TIMEOUT_MS = 300_000 +// Cross-process extractors are bounded at five minutes; retain a second window before cleanup. +const STALE_EXTRACTION_GRACE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 2 + +const activeExtractionPaths = new Set() + +export function trackAppImageExtraction(stagingPath: string): () => void { + activeExtractionPaths.add(stagingPath) + return () => activeExtractionPaths.delete(stagingPath) +} + +export function getAppImageActiveExtractionPath(rootPath: string): string { + return join(dirname(rootPath), `${ACTIVE_EXTRACTION_PREFIX}${basename(rootPath)}`) +} + +export async function pruneAppImageExtractedRoots(keepRootPath: string): Promise { + const resolvedKeepRoot = resolve(keepRootPath) + const namespacePath = dirname(resolvedKeepRoot) + const cacheRootPath = dirname(namespacePath) + const protectedRoots = new Set([resolvedKeepRoot]) + const installedRoot = await resolveInstalledRoot(cacheRootPath) + if (installedRoot && dirname(installedRoot) === namespacePath) { + protectedRoots.add(installedRoot) + } + await pruneNamespace(namespacePath, protectedRoots) + await rmdir(namespacePath).catch(() => {}) +} + +export async function removeAppImageInstalledPayloads(namespacePath: string): Promise { + const resolvedNamespace = resolve(namespacePath) + const cacheRootPath = dirname(resolvedNamespace) + removeAppImageLegacyLiveEndpoint(cacheRootPath) + if (await removeInstalledEndpoint(cacheRootPath, resolvedNamespace)) { + await pruneNamespace(resolvedNamespace, new Set()) + await rmdir(resolvedNamespace).catch(() => {}) + } +} + +async function resolveInstalledRoot(cacheRootPath: string): Promise { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + const targetPath = resolve(dirname(endpointPath), await readlink(endpointPath)) + return resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + } catch { + return null + } +} + +async function removeInstalledEndpoint( + cacheRootPath: string, + namespacePath: string +): Promise { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + if (!(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath))) { + return true + } + + const displacedPath = join( + dirname(endpointPath), + `.orca-preserved-installed-${process.pid}-${randomUUID()}` + ) + try { + await rename(endpointPath, displacedPath) + } catch { + return !(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath)) + } + + if (await endpointTargetsNamespace(cacheRootPath, displacedPath, namespacePath)) { + await unlink(displacedPath).catch(() => {}) + return true + } + + try { + await symlink(await readlink(displacedPath), endpointPath) + await unlink(displacedPath) + } catch {} + return !(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath)) +} + +async function endpointTargetsNamespace( + cacheRootPath: string, + endpointPath: string, + namespacePath: string +): Promise { + try { + const targetPath = resolve(dirname(endpointPath), await readlink(endpointPath)) + const targetRoot = resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + return targetRoot !== null && dirname(targetRoot) === namespacePath + } catch { + return false + } +} + +async function pruneNamespace( + namespacePath: string, + protectedRoots: ReadonlySet +): Promise { + let entries: Dirent[] + try { + entries = await readdir(namespacePath, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const entryPath = join(namespacePath, entry.name) + if ( + entry.name.startsWith(EXTRACTION_STAGING_PREFIX) || + entry.name.startsWith(ACTIVE_EXTRACTION_PREFIX) + ) { + if (activeExtractionPaths.has(entryPath) || !(await isOlderThanGrace(entryPath))) { + continue + } + } else if (!isAppImageCacheKey(entry.name)) { + continue + } else if ( + protectedRoots.has(resolve(entryPath)) || + (existsSync(getAppImageActiveExtractionPath(entryPath)) && + !(await isOlderThanGrace(getAppImageActiveExtractionPath(entryPath)))) + ) { + continue + } + // Best effort, but never silent: a swallowed failure here leaks a whole payload generation. + await removeExtractedAppImagePayload(entryPath).catch((error: unknown) => { + console.warn( + `[cli] could not reclaim AppImage payload ${entryPath}:`, + error instanceof Error ? error.message : error + ) + }) + } +} + +async function isOlderThanGrace(candidatePath: string): Promise { + try { + return Date.now() - (await lstat(candidatePath)).mtimeMs >= STALE_EXTRACTION_GRACE_MS + } catch { + return true + } +} diff --git a/src/main/cli/appimage-payload-removal.reentrancy.test.ts b/src/main/cli/appimage-payload-removal.reentrancy.test.ts new file mode 100644 index 00000000000..ebf9c399c9e --- /dev/null +++ b/src/main/cli/appimage-payload-removal.reentrancy.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why a mocked rm: the property under test is the ORDER in which overlapping removals settle, which +// real filesystem timing cannot pin down. Deferreds make the interleaving exact. +const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ rm: rmMock })) + +const originalNoAsar = process.noAsar + +afterEach(() => { + process.noAsar = originalNoAsar + vi.resetModules() +}) + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +describe('removeExtractedAppImagePayload reentrancy', () => { + // The hazard is the FIRST removal settling while a later one is still running: a naive + // save/restore would hand the shim back and silently leak the rest of the second removal. + it('keeps asar interception disabled while a later removal is still running', async () => { + process.noAsar = false + const first = deferred() + const second = deferred() + rmMock.mockReset() + rmMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { removeExtractedAppImagePayload } = await import('./appimage-payload-removal') + + const firstCall = removeExtractedAppImagePayload('/cache/gen-a') + const secondCall = removeExtractedAppImagePayload('/cache/gen-b') + expect(process.noAsar).toBe(true) + + first.resolve() + await firstCall + // gen-b is still being removed: handing the shim back here is exactly the leak. + expect(process.noAsar).toBe(true) + + second.resolve() + await secondCall + expect(process.noAsar).toBe(false) + }) + + it('keeps the flag held when the first removal rejects mid-overlap', async () => { + process.noAsar = false + const second = deferred() + rmMock.mockReset() + rmMock.mockRejectedValueOnce(new Error('EACCES')).mockReturnValueOnce(second.promise) + const { removeExtractedAppImagePayload } = await import('./appimage-payload-removal') + + const firstCall = removeExtractedAppImagePayload('/cache/gen-a') + const secondCall = removeExtractedAppImagePayload('/cache/gen-b') + + await expect(firstCall).rejects.toThrow('EACCES') + expect(process.noAsar).toBe(true) + + second.resolve() + await secondCall + expect(process.noAsar).toBe(false) + }) +}) diff --git a/src/main/cli/appimage-payload-removal.test.ts b/src/main/cli/appimage-payload-removal.test.ts new file mode 100644 index 00000000000..caa3ee33c1c --- /dev/null +++ b/src/main/cli/appimage-payload-removal.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' + +const originalNoAsar = process.noAsar + +afterEach(() => { + process.noAsar = originalNoAsar +}) + +async function makePayloadTree(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-payload-removal-')) + await mkdir(join(root, 'resources'), { recursive: true }) + // The real leak: Electron's fs patch reports a *.asar file as a directory. + await writeFile(join(root, 'resources', 'app.asar'), 'asar-payload') + await writeFile(join(root, 'AppRun'), '#!/bin/sh\n') + return root +} + +describe('removeExtractedAppImagePayload', () => { + it('removes a payload tree containing an asar file', async () => { + const root = await makePayloadTree() + await removeExtractedAppImagePayload(root) + expect(existsSync(root)).toBe(false) + }) + + it('disables asar interception for the removal', async () => { + const root = await makePayloadTree() + let observed: boolean | undefined + const originalRealpath = process.noAsar + Object.defineProperty(process, 'noAsar', { + configurable: true, + get: () => observed ?? originalRealpath, + set: (value: boolean) => { + observed ??= value + } + }) + try { + await removeExtractedAppImagePayload(root) + } finally { + Object.defineProperty(process, 'noAsar', { + configurable: true, + writable: true, + value: originalRealpath + }) + } + expect(observed).toBe(true) + }) + + it('restores the previous asar setting after a failure', async () => { + process.noAsar = false + await expect( + removeExtractedAppImagePayload(join(tmpdir(), 'orca-missing', 'nested', '\0invalid')) + ).rejects.toThrow() + expect(process.noAsar).toBe(false) + }) + + it('is a no-op for a path that does not exist', async () => { + await expect( + removeExtractedAppImagePayload(join(tmpdir(), 'orca-payload-removal-absent')) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/cli/appimage-payload-removal.ts b/src/main/cli/appimage-payload-removal.ts new file mode 100644 index 00000000000..86a6a326cee --- /dev/null +++ b/src/main/cli/appimage-payload-removal.ts @@ -0,0 +1,35 @@ +import { rm } from 'node:fs/promises' + +// Why a counter and not a saved value: `process.noAsar` is process-wide, so two overlapping +// removals would race — the first to settle would restore the shim while the second is still +// running, and the rest of that removal would silently leak again. Removals are sequential today; +// this keeps that a property of the module rather than of its callers. +let activeRemovals = 0 +// Captured once when the outermost removal starts; `process.noAsar` is typed boolean, and an +// unset flag is falsy, so restoring `false` is equivalent to restoring `undefined`. +let asarBeforeOutermostRemoval = false + +/** + * Removes an extracted AppImage payload tree. + * + * Why not a plain recursive `rm`: Electron patches `fs` so a `*.asar` file reports + * `isDirectory() === true`. A recursive remove then tries to `rmdir` a real file, fails with + * ENOTEMPTY, and strands the ~105 MB `resources/app.asar` of every superseded generation. + * `process.noAsar` restores real filesystem semantics; the window is ~33 ms for a full 519 MB + * generation, and nothing in the registration path reads asar content inside it. + */ +export async function removeExtractedAppImagePayload(targetPath: string): Promise { + if (activeRemovals === 0) { + asarBeforeOutermostRemoval = process.noAsar === true + } + activeRemovals += 1 + process.noAsar = true + try { + await rm(targetPath, { recursive: true, force: true }) + } finally { + activeRemovals -= 1 + if (activeRemovals === 0) { + process.noAsar = asarBeforeOutermostRemoval + } + } +} diff --git a/src/main/cli/appimage-registration-lock.test.ts b/src/main/cli/appimage-registration-lock.test.ts new file mode 100644 index 00000000000..f32cf7d5b0d --- /dev/null +++ b/src/main/cli/appimage-registration-lock.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { lockMock, mkdirMock } = vi.hoisted(() => ({ lockMock: vi.fn(), mkdirMock: vi.fn() })) + +vi.mock('proper-lockfile', () => ({ lock: lockMock })) +vi.mock('node:fs/promises', () => ({ mkdir: mkdirMock })) + +afterEach(() => { + vi.resetModules() +}) + +async function load() { + mkdirMock.mockReset().mockResolvedValue(undefined) + return import('./appimage-registration-lock') +} + +describe('withAppImageRegistrationLock', () => { + it('bounds the wait with a wall-clock deadline, not just an attempt count', async () => { + lockMock.mockReset().mockResolvedValue(vi.fn().mockResolvedValue(undefined)) + const { withAppImageRegistrationLock } = await load() + + await withAppImageRegistrationLock('/cache/orca/appimage', async () => 'done') + + const options = lockMock.mock.calls[0][1] + // Why: `retries` alone caps attempts, not elapsed time — 1000 x 1s is ~16 minutes. + expect(options.retries.maxRetryTime).toBeGreaterThan(0) + expect(options.retries.maxRetryTime).toBeLessThanOrEqual(options.stale) + }) + + it('reports a wedged holder with a remedy instead of hanging', async () => { + lockMock.mockReset().mockRejectedValue(Object.assign(new Error('ELOCKED'), { code: 'ELOCKED' })) + const { withAppImageRegistrationLock } = await load() + + await expect( + withAppImageRegistrationLock('/cache/orca/appimage', async () => 'done') + ).rejects.toThrow(/Timed out waiting for another Orca process[\s\S]*remove .*\.lock/) + }) + + it('releases the lock when the operation throws', async () => { + const release = vi.fn().mockResolvedValue(undefined) + lockMock.mockReset().mockResolvedValue(release) + const { withAppImageRegistrationLock } = await load() + + await expect( + withAppImageRegistrationLock('/cache/orca/appimage', async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + expect(release).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/cli/appimage-registration-lock.ts b/src/main/cli/appimage-registration-lock.ts new file mode 100644 index 00000000000..7c6350e249f --- /dev/null +++ b/src/main/cli/appimage-registration-lock.ts @@ -0,0 +1,48 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { lock } from 'proper-lockfile' +import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning' + +const LOCK_TARGET_NAME = '.cli-registration' +const LOCK_STALE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 3 +// Why a wall-clock deadline: `retries` alone bounds the attempt count, not the wait — 1000 attempts +// at up to 1s each let an IPC-driven registration hang ~16 minutes against a wedged holder with no +// feedback. A legitimate holder is bounded by the extraction timeout, so anything past that plus +// slack is wedged, and failing with a message beats hanging. +const LOCK_ACQUIRE_DEADLINE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS + 30_000 +const LOCK_RETRIES = { + retries: 1_000, + factor: 1.2, + minTimeout: 25, + maxTimeout: 1_000, + randomize: true, + maxRetryTime: LOCK_ACQUIRE_DEADLINE_MS +} + +export async function withAppImageRegistrationLock( + cacheRootPath: string, + operation: () => Promise +): Promise { + await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + let release: () => Promise + try { + release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { + realpath: false, + retries: LOCK_RETRIES, + stale: LOCK_STALE_MS, + update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 + }) + } catch (error) { + throw new Error( + `Timed out waiting for another Orca process to finish CLI registration ` + + `(waited ${Math.round(LOCK_ACQUIRE_DEADLINE_MS / 1000)}s). ` + + `If no other Orca is running, remove ${join(cacheRootPath, LOCK_TARGET_NAME)}.lock and retry.`, + { cause: error } + ) + } + try { + return await operation() + } finally { + await release() + } +} diff --git a/src/main/cli/appimage-stable-launcher.test.ts b/src/main/cli/appimage-stable-launcher.test.ts new file mode 100644 index 00000000000..b7830443b4b --- /dev/null +++ b/src/main/cli/appimage-stable-launcher.test.ts @@ -0,0 +1,181 @@ +import { existsSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +const { filePublicationFailures } = vi.hoisted(() => ({ + filePublicationFailures: { copy: 0, link: 0, replaceBeforeRename: '' } +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + copyFileSync: (...args: Parameters) => { + if (filePublicationFailures.copy > 0) { + filePublicationFailures.copy -= 1 + throw Object.assign(new Error('copy unsupported'), { code: 'ENOTSUP' }) + } + return actual.copyFileSync(...args) + }, + linkSync: (...args: Parameters) => { + if (filePublicationFailures.link > 0) { + filePublicationFailures.link -= 1 + throw Object.assign(new Error('link unsupported'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + }, + renameSync: (...args: Parameters) => { + if (filePublicationFailures.replaceBeforeRename) { + actual.writeFileSync(args[0], filePublicationFailures.replaceBeforeRename, { mode: 0o755 }) + filePublicationFailures.replaceBeforeRename = '' + } + return actual.renameSync(...args) + } + } +}) +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + filePublicationFailures.copy = 0 + filePublicationFailures.link = 0 + filePublicationFailures.replaceBeforeRename = '' + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture(): Promise { + const cacheRootPath = await mkdtemp(join(tmpdir(), 'orca-stable-appimage-launcher-')) + created.push(cacheRootPath) + return cacheRootPath +} + +async function writeLauncher(path: string, output: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `#!/usr/bin/env bash\nprintf '${output}'`, { mode: 0o755 }) +} + +describe.skipIf(process.platform === 'win32')('AppImage stable launcher', () => { + it('uses only the installed payload and ignores a legacy live endpoint', async () => { + const cacheRootPath = await makeFixture() + const livePath = join(cacheRootPath, 'payloads', 'live') + const installedPath = join(cacheRootPath, 'payloads', 'installed') + await Promise.all([writeLauncher(livePath, 'live'), writeLauncher(installedPath, 'installed')]) + + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', installedPath)! + await symlink(livePath, resolveAppImageLauncherEndpointPath(cacheRootPath, 'live')) + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'installed' }) + expect(await readFile(launcherPath, 'utf8')).not.toContain('/live') + }) + + it('observes an endpoint published after the wrapper starts', async () => { + const cacheRootPath = await makeFixture() + const missingPath = join(cacheRootPath, 'payloads', 'missing') + const readyPath = join(cacheRootPath, 'payloads', 'ready') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', missingPath)! + const invocation = runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + setTimeout(() => { + void writeLauncher(readyPath, 'ready').then(() => { + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', readyPath) + }) + }, 100) + + await expect(invocation).resolves.toMatchObject({ code: 0, stdout: 'ready' }) + }) + + it('atomically upgrades a stale marker-owned launcher', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBe( + launcherPath + ) + expect(await readFile(launcherPath, 'utf8')).toContain('launcher_dir=') + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'target' }) + }) + + it('publishes on a cache filesystem without hard-link support', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + filePublicationFailures.link = 1 + + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath) + + expect(launcherPath).toBe(resolveAppImageStableLauncherPath(cacheRootPath)) + await expect(readFile(launcherPath!, 'utf8')).resolves.toContain('launcher_dir=') + }) + + it('restores a displaced owned launcher when its replacement cannot be published', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + const staleContent = `#!/usr/bin/env bash\n${marker}\nprintf stale` + await writeFile(launcherPath, staleContent, { mode: 0o755 }) + filePublicationFailures.link = 2 + filePublicationFailures.copy = 2 + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(staleContent) + }) + + it('restores a displaced foreign launcher when hard links are unsupported', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) + const foreignContent = '#!/usr/bin/env bash\nprintf foreign' + filePublicationFailures.replaceBeforeRename = foreignContent + filePublicationFailures.link = 2 + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(foreignContent) + }) + + it('preserves a foreign launcher and declines endpoint publication', async () => { + const cacheRootPath = await makeFixture() + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign', { mode: 0o755 }) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', join(cacheRootPath, 'target')) + ).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') + expect(existsSync(endpointPath)).toBe(false) + }) + + it('preserves an oversized foreign launcher without treating its marker as ownership', async () => { + const cacheRootPath = await makeFixture() + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = `#!/usr/bin/env bash\n# orca-appimage-stable-launcher\n${'x'.repeat(20_000)}` + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, content, { mode: 0o755 }) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', join(cacheRootPath, 'target')) + ).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(content) + }) +}) diff --git a/src/main/cli/appimage-stable-launcher.ts b/src/main/cli/appimage-stable-launcher.ts new file mode 100644 index 00000000000..9494479abd8 --- /dev/null +++ b/src/main/cli/appimage-stable-launcher.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + constants, + copyFileSync, + fstatSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + symlinkSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { quoteShell } from './cli-install-path-format' + +const LAUNCHER_DIRECTORY_NAME = 'launcher' +const LIVE_ENDPOINT_NAME = 'live' +const INSTALLED_ENDPOINT_NAME = 'installed' +const LAUNCHER_MARKER = '# orca-appimage-stable-launcher' +const LAUNCHER_WAIT_SECONDS = 5 +const LAUNCHER_MAX_BYTES = 16 * 1024 + +export type AppImageLauncherEndpoint = 'live' | 'installed' + +export function resolveAppImageStableLauncherPath(cacheRootPath: string): string { + return join(resolve(cacheRootPath), LAUNCHER_DIRECTORY_NAME, LINUX_CLI_COMMAND_NAME) +} + +export function resolveAppImageLauncherEndpointPath( + cacheRootPath: string, + endpoint: AppImageLauncherEndpoint +): string { + return join( + dirname(resolveAppImageStableLauncherPath(cacheRootPath)), + endpoint === 'live' ? LIVE_ENDPOINT_NAME : INSTALLED_ENDPOINT_NAME + ) +} + +export function isAppImageStableLauncherReady(cacheRootPath: string): boolean { + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + return isExactExecutableLauncher(launcherPath, buildStableLauncherScript(cacheRootPath)) +} + +/** Ensures the persistent wrapper exists without publishing an endpoint. */ +export function ensureAppImageStableLauncher(cacheRootPath: string): string | null { + return ensureAppImageStableLauncherFile(cacheRootPath) +} + +/** Removes the legacy live endpoint only when it is a symlink. */ +export function removeAppImageLegacyLiveEndpoint(cacheRootPath: string): void { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + try { + if (lstatSync(endpointPath).isSymbolicLink()) { + unlinkSync(endpointPath) + } + } catch {} +} + +export function publishAppImageLauncherEndpoint( + cacheRootPath: string, + endpoint: 'installed', + targetPath: string +): string | null { + const launcherPath = ensureAppImageStableLauncherFile(cacheRootPath) + if (!launcherPath) { + return null + } + + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, endpoint) + const temporaryPath = join(dirname(endpointPath), `.${endpoint}-${process.pid}-${randomUUID()}`) + try { + symlinkSync(targetPath, temporaryPath) + renameSync(temporaryPath, endpointPath) + return launcherPath + } catch { + return null + } finally { + try { + unlinkSync(temporaryPath) + } catch {} + } +} + +function ensureAppImageStableLauncherFile(cacheRootPath: string): string | null { + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = buildStableLauncherScript(cacheRootPath) + try { + mkdirSync(dirname(launcherPath), { recursive: true }) + if (!installLauncher(launcherPath, content)) { + return null + } + return launcherPath + } catch { + return null + } +} + +function installLauncher(launcherPath: string, content: string): boolean { + if (isExactExecutableLauncher(launcherPath, content)) { + return true + } + const temporaryPath = join( + dirname(launcherPath), + `.${LINUX_CLI_COMMAND_NAME}-${process.pid}-${randomUUID()}` + ) + try { + writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o755 }) + if (publishLauncherIfVacant(temporaryPath, launcherPath)) { + return true + } + if (!isOwnedLauncher(launcherPath)) { + return false + } + return replaceOwnedLauncher(launcherPath, temporaryPath, content) + } finally { + unlinkIfPresent(temporaryPath) + } +} + +function replaceOwnedLauncher( + launcherPath: string, + replacementPath: string, + replacementContent: string +): boolean { + const displacedPath = join( + dirname(launcherPath), + `.orca-preserved-launcher-${process.pid}-${randomUUID()}` + ) + try { + renameSync(launcherPath, displacedPath) + } catch { + return isExactExecutableLauncher(launcherPath, replacementContent) + } + + if (!isOwnedLauncher(displacedPath)) { + restoreForeignLauncher(displacedPath, launcherPath) + return false + } + + if ( + publishLauncherIfVacant(replacementPath, launcherPath) || + isExactExecutableLauncher(launcherPath, replacementContent) + ) { + unlinkIfPresent(displacedPath) + return true + } + + if (publishLauncherIfVacant(displacedPath, launcherPath)) { + unlinkIfPresent(displacedPath) + } + return isExactExecutableLauncher(launcherPath, replacementContent) +} + +function restoreForeignLauncher(displacedPath: string, launcherPath: string): void { + if (publishLauncherIfVacant(displacedPath, launcherPath)) { + unlinkIfPresent(displacedPath) + } +} + +function publishLauncherIfVacant(sourcePath: string, destinationPath: string): boolean { + try { + linkSync(sourcePath, destinationPath) + return true + } catch {} + try { + copyFileSync(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return true + } catch { + return false + } +} + +function isExactExecutableLauncher(launcherPath: string, content: string): boolean { + const launcher = readLauncherFile(launcherPath) + return launcher?.executable === true && launcher.content === content +} + +function isOwnedLauncher(launcherPath: string): boolean { + return readLauncherFile(launcherPath)?.content.split('\n')[1] === LAUNCHER_MARKER +} + +function readLauncherFile(launcherPath: string): { content: string; executable: boolean } | null { + let fd: number | undefined + try { + fd = openSync(launcherPath, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) + const before = fstatSync(fd) + if (!before.isFile() || before.size > LAUNCHER_MAX_BYTES) { + return null + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const count = readSync(fd, bytes, offset, bytes.length - offset, offset) + if (count === 0) { + return null + } + offset += count + } + const after = fstatSync(fd) + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + return null + } + return { content: bytes.toString('utf8'), executable: (before.mode & 0o111) !== 0 } + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function unlinkIfPresent(candidatePath: string): void { + try { + unlinkSync(candidatePath) + } catch {} +} + +function buildStableLauncherScript(cacheRootPath: string): string { + const launcherDirectory = dirname(resolveAppImageStableLauncherPath(cacheRootPath)) + return `#!/usr/bin/env bash +${LAUNCHER_MARKER} +shopt -s execfail +launcher_dir=${quoteShell(launcherDirectory)} +deadline=$((SECONDS + ${LAUNCHER_WAIT_SECONDS})) +while (( SECONDS <= deadline )); do + launcher="$launcher_dir/${INSTALLED_ENDPOINT_NAME}" + if [[ -f "$launcher" && -x "$launcher" ]]; then + exec "$launcher" "$@" + fi + sleep 0.1 +done +printf 'Orca CLI is not ready; reopen Orca or register the CLI again.\\n' >&2 +exit 1 +` +} diff --git a/src/main/cli/cli-command-filesystem-transaction.ts b/src/main/cli/cli-command-filesystem-transaction.ts new file mode 100644 index 00000000000..b5e29386f8e --- /dev/null +++ b/src/main/cli/cli-command-filesystem-transaction.ts @@ -0,0 +1,209 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lstat, mkdir, readFile, readlink, rename, rmdir } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { isMissingError } from './cli-install-errors' +import { quoteShell } from './cli-install-path-format' + +export type EntryIdentity = { + dev: bigint + ino: bigint + mode: bigint + size: bigint + ctimeNs: bigint +} + +export type EntrySnapshot = { identity: EntryIdentity; isSymbolicLink: boolean } +export type CommandQuarantine = { + directoryPath: string + heldPath: string + snapshot: EntrySnapshot | null +} +export type StableCommandInspection = { + fileSha256: string | null + rawSymlinkTarget: string | null + snapshot: EntrySnapshot | null + status: CliInstallStatus +} + +const STABLE_INSPECTION_ATTEMPTS = 3 + +export async function readEntrySnapshot(path: string): Promise { + try { + const stats = await lstat(path, { bigint: true }) + return { + identity: { + dev: stats.dev, + ino: stats.ino, + mode: stats.mode, + size: stats.size, + ctimeNs: stats.ctimeNs + }, + isSymbolicLink: stats.isSymbolicLink() + } + } catch (error) { + if (isMissingError(error)) { + return null + } + throw error + } +} + +export function hasSameIdentity(left: EntryIdentity | null, right: EntryIdentity | null): boolean { + return ( + left === right || + (left !== null && right !== null && left.dev === right.dev && left.ino === right.ino) + ) +} + +export function hasSameSnapshot(left: EntrySnapshot | null, right: EntrySnapshot | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + hasSameIdentity(left.identity, right.identity) && + left.identity.mode === right.identity.mode && + left.identity.size === right.identity.size && + left.identity.ctimeNs === right.identity.ctimeNs) + ) +} + +export async function hashCommandFile(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +export async function inspectStableCommand( + commandPath: string, + inspect: () => Promise +): Promise { + for (let attempt = 0; attempt < STABLE_INSPECTION_ATTEMPTS; attempt += 1) { + const before = await readEntrySnapshot(commandPath) + const status = await inspect() + const afterInspection = await readEntrySnapshot(commandPath) + if ( + !hasSameSnapshot(before, afterInspection) || + (afterInspection === null) !== (status.state === 'not_installed') + ) { + continue + } + let fileSha256: string | null = null + let rawSymlinkTarget: string | null = null + try { + if (afterInspection?.isSymbolicLink) { + rawSymlinkTarget = await readlink(commandPath) + } else if (afterInspection && status.state !== 'conflict') { + fileSha256 = await hashCommandFile(commandPath) + } + } catch { + continue + } + const afterEvidence = await readEntrySnapshot(commandPath) + if (hasSameSnapshot(afterInspection, afterEvidence)) { + return { fileSha256, rawSymlinkTarget, snapshot: afterEvidence, status } + } + } + throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) +} + +export async function quarantineCommandPath(commandPath: string): Promise { + const commandDirectory = dirname(commandPath) + const directoryPath = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`) + const heldPath = join(directoryPath, basename(commandPath)) + await mkdir(commandDirectory, { recursive: true }) + await mkdir(directoryPath, { mode: 0o700 }) + try { + await rename(commandPath, heldPath) + } catch (error) { + if (!isMissingError(error)) { + await rmdir(directoryPath).catch(() => undefined) + throw error + } + } + return { directoryPath, heldPath, snapshot: await readEntrySnapshot(heldPath) } +} + +export async function capturedExpectedEntry( + quarantine: CommandQuarantine, + inspected: Pick +): Promise { + if (!quarantine.snapshot) { + return true + } + if ( + !inspected.snapshot || + quarantine.snapshot.isSymbolicLink !== inspected.snapshot.isSymbolicLink || + !hasSameIdentity(quarantine.snapshot.identity, inspected.snapshot.identity) + ) { + return false + } + if (inspected.rawSymlinkTarget !== null) { + try { + return (await readlink(quarantine.heldPath)) === inspected.rawSymlinkTarget + } catch { + return false + } + } + if (!inspected.fileSha256) { + return true + } + try { + return (await hashCommandFile(quarantine.heldPath)) === inspected.fileSha256 + } catch { + return false + } +} + +type MacPrivilegedSymlinkTransaction = { + commandPath: string + expected: EntryIdentity | null + expectedFileSha256: string | null + expectedRawSymlinkTarget: string | null +} & ({ action: 'install'; launcherPath: string } | { action: 'remove' }) + +export function buildMacPrivilegedSymlinkTransaction( + args: MacPrivilegedSymlinkTransaction +): string { + const commandDirectory = dirname(args.commandPath) + const transactionDirectory = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`) + const heldPath = join(transactionDirectory, basename(args.commandPath)) + const publishDirectory = join(transactionDirectory, 'publish') + const publishPath = join(publishDirectory, basename(args.commandPath)) + const recoveryMessage = quoteShell(`The displaced entry is preserved at ${heldPath}.`) + const restore = + `/bin/ln -P ${quoteShell(heldPath)} ${quoteShell(commandDirectory)} && ` + + `/bin/rm ${quoteShell(heldPath)} && /bin/rmdir ${quoteShell(transactionDirectory)}` + const restoreOrPreserve = `if ${restore}; then :; else echo ${recoveryMessage} >&2; exit 74; fi` + const fileMismatch = args.expectedFileSha256 + ? ` || [ "$(/usr/bin/shasum -a 256 ${quoteShell(heldPath)} | /usr/bin/awk '{print $1}')" != ${quoteShell(args.expectedFileSha256)} ]` + : '' + const symlinkMismatch = args.expectedRawSymlinkTarget + ? ` || [ "$(/usr/bin/readlink -n ${quoteShell(heldPath)}; /usr/bin/printf x)" != ${quoteShell(`${args.expectedRawSymlinkTarget}x`)} ]` + : '' + const rejectCaptured = args.expected + ? `if [ "$captured" -eq 1 ] && { [ "$(/usr/bin/stat -f '%d:%i' ${quoteShell(heldPath)})" != ${quoteShell(`${args.expected.dev}:${args.expected.ino}`)} ]${fileMismatch}${symlinkMismatch}; }; then ${restoreOrPreserve}; exit 73; fi` + : `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; exit 73; fi` + const capture = + `umask 077; /bin/mkdir -p ${quoteShell(commandDirectory)} || exit $?; ` + + `/bin/mkdir ${quoteShell(transactionDirectory)} || exit $?; captured=0; ` + + `if [ -e ${quoteShell(args.commandPath)} ] || [ -L ${quoteShell(args.commandPath)} ]; then ` + + `/bin/mv ${quoteShell(args.commandPath)} ${quoteShell(heldPath)} && captured=1 || exit $?; fi; ` + + `${rejectCaptured}; ` + + if (args.action === 'remove') { + return `${capture}if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; /bin/rmdir ${quoteShell(transactionDirectory)}` + } + + const rollback = + `/bin/rm -f ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)} 2>/dev/null || :; ` + + `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quoteShell(transactionDirectory)}; fi; exit 73` + return ( + `${capture}if /bin/mkdir ${quoteShell(publishDirectory)} && ` + + `/bin/ln -s ${quoteShell(args.launcherPath)} ${quoteShell(publishPath)} && ` + + `/bin/ln -P ${quoteShell(publishPath)} ${quoteShell(commandDirectory)}; then ` + + `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + + `if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; ` + + `/bin/rmdir ${quoteShell(transactionDirectory)}; else ${rollback}; fi` + ) +} diff --git a/src/main/cli/cli-command-inspection.ts b/src/main/cli/cli-command-inspection.ts index 93712201fc5..c580c597478 100644 --- a/src/main/cli/cli-command-inspection.ts +++ b/src/main/cli/cli-command-inspection.ts @@ -1,62 +1,21 @@ +import { existsSync } from 'node:fs' import { lstat, readFile, readlink } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { isAppImageExtractedLauncherPath } from './appimage-extracted-root' import { DEV_COMMAND_NAME, DEV_LAUNCHER_DIR } from './cli-install-constants' import { buildWindowsForwarder, extractManagedUnixLauncherTarget } from './cli-dev-launcher' import { isMissingError } from './cli-install-errors' import { CliInstallLocation } from './cli-install-location' import { isPathInsideOrEqual, samePathEntry } from './cli-install-path-format' +import { extractLegacyAppImageCliWrapperTarget } from './legacy-appimage-cli-wrapper' + +// Why: electron-builder's /opt directory name varies with productName sanitization, which is why +// resources/linux/packaging/after-install.sh enumerates all three of these. A symlink into one is a +// previous packaged Orca and is ours to reclaim; anything else stays a conflict. +const PACKAGED_LINUX_LAUNCHER_DIRECTORIES = ['/opt/Orca', '/opt/orca-ide', '/opt/orca'] export class CliCommandInspection extends CliInstallLocation { - protected async inspectAppImageWrapper( - commandPath: string, - appImagePath: string - ): Promise { - try { - const stats = await lstat(commandPath) - if (!stats.isFile()) { - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: 'conflict', - currentTarget: null, - detail: `${commandPath} exists but is not an Orca launcher script.` - }) - } - - const currentContent = await readFile(commandPath, 'utf8') - const expectedContent = buildAppImageCliWrapper(appImagePath) - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: currentContent === expectedContent ? 'installed' : 'stale', - currentTarget: appImagePath, - detail: - currentContent === expectedContent - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` - }) - } catch (error) { - if (isMissingError(error)) { - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: 'not_installed', - currentTarget: null, - detail: `Register ${commandPath} to use Orca from the terminal.` - }) - } - throw error - } - } - protected async inspectSymlink( commandPath: string, launcherPath: string @@ -66,7 +25,9 @@ export class CliCommandInspection extends CliInstallLocation { if (!stats.isSymbolicLink()) { if (stats.isFile()) { const currentContent = await readFile(commandPath, 'utf8') - const managedTarget = extractManagedUnixLauncherTarget(currentContent) + const managedTarget = + extractManagedUnixLauncherTarget(currentContent) ?? + extractLegacyAppImageCliWrapperTarget(currentContent) if (managedTarget) { return this.buildStatus({ commandPath, @@ -94,9 +55,11 @@ export class CliCommandInspection extends CliInstallLocation { const currentTarget = await readlink(commandPath) const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget) const resolvedLauncher = resolve(launcherPath) - const isInstalled = resolvedCurrentTarget === resolvedLauncher + const isInstalled = resolvedCurrentTarget === resolvedLauncher && existsSync(resolvedLauncher) const isManagedStaleTarget = - !isInstalled && this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath) + !isInstalled && + (resolvedCurrentTarget === resolvedLauncher || + this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath)) return this.buildStatus({ commandPath, launcherPath, @@ -149,12 +112,25 @@ export class CliCommandInspection extends CliInstallLocation { } if (this.platform === 'linux') { - return /(?:^|[/\\])resources[/\\]bin[/\\][^/\\]+$/.test(resolvedTarget) + if (this.isPackagedLinuxLauncherTarget(resolvedTarget, expectedName)) { + return true + } + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? isAppImageExtractedLauncherPath(extractionOptions, resolvedTarget) + : false } return false } + /** A launcher inside a packaged Linux install tree, left behind by a deb/rpm Orca. */ + protected isPackagedLinuxLauncherTarget(resolvedTarget: string, expectedName: string): boolean { + return PACKAGED_LINUX_LAUNCHER_DIRECTORIES.some( + (directory) => resolvedTarget === `${directory}/resources/bin/${expectedName}` + ) + } + protected isSiblingDevLauncherTarget( resolvedTarget: string, packagedLauncherName: string diff --git a/src/main/cli/cli-command-installation-races.test.ts b/src/main/cli/cli-command-installation-races.test.ts new file mode 100644 index 00000000000..34c7f6dfe7d --- /dev/null +++ b/src/main/cli/cli-command-installation-races.test.ts @@ -0,0 +1,386 @@ +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + readlink, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const legacyReadlinkRace = vi.hoisted(() => ({ commandPath: '', replacementTarget: '' })) +const reusedIdentity = vi.hoisted(() => ({ + path: '', + dev: null as bigint | null, + ino: null as bigint | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstat: async (...args: Parameters) => { + const stats = await actual.lstat(...args) + if ( + args[0] !== reusedIdentity.path || + reusedIdentity.dev === null || + reusedIdentity.ino === null + ) { + return stats + } + return Object.create(stats, { + dev: { value: reusedIdentity.dev }, + ino: { value: reusedIdentity.ino } + }) as typeof stats + }, + readlink: async (...args: Parameters) => { + const [path] = args + if (path === legacyReadlinkRace.commandPath && legacyReadlinkRace.replacementTarget) { + const replacementTarget = legacyReadlinkRace.replacementTarget + legacyReadlinkRace.commandPath = '' + legacyReadlinkRace.replacementTarget = '' + await actual.unlink(path) + await actual.symlink(replacementTarget, path) + throw Object.assign(new Error('link vanished during inspection'), { code: 'ENOENT' }) + } + return actual.readlink(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' +import type { CommandQuarantine } from './cli-command-filesystem-transaction' + +const createdRoots: string[] = [] + +afterEach(async () => { + legacyReadlinkRace.commandPath = '' + legacyReadlinkRace.replacementTarget = '' + reusedIdentity.path = '' + reusedIdentity.dev = null + reusedIdentity.ino = null + await Promise.all( + createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function createMacCommandFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-command-race-')) + createdRoots.push(root) + const commandDirectory = join(root, 'bin') + const commandPath = join(commandDirectory, 'orca') + const resourcesPath = join(root, 'Current.app', 'Contents', 'Resources') + const launcherPath = join(resourcesPath, 'bin', 'orca') + const staleLauncherPath = join(root, 'Old.app', 'Contents', 'Resources', 'bin', 'orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, commandDirectory, commandPath, resourcesPath, launcherPath, staleLauncherPath } +} + +function createMacInstaller( + fixture: Awaited>, + hooks: { + quarantine?: (commandPath: string) => Promise + afterQuarantine?: (quarantine: CommandQuarantine) => void + link?: (heldPath: string, commandPath: string) => Promise + } = {} +): CliInstaller { + class RaceInjectedInstaller extends CliInstaller { + protected override async quarantineCommandPath(commandPath: string) { + await hooks.quarantine?.(commandPath) + const quarantine = await super.quarantineCommandPath(commandPath) + hooks.afterQuarantine?.(quarantine) + return quarantine + } + + protected override async linkQuarantinedCommand( + heldPath: string, + commandPath: string + ): Promise { + await hooks.link?.(heldPath, commandPath) + return super.linkQuarantinedCommand(heldPath, commandPath) + } + } + + return new RaceInjectedInstaller({ + platform: 'darwin', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath, + execPath: join(fixture.root, 'Current.app', 'Contents', 'MacOS', 'Orca'), + appPath: join(fixture.root, 'Current.app', 'Contents', 'Resources', 'app.asar'), + homePath: join(fixture.root, 'home'), + commandPathOverride: fixture.commandPath, + processPathEnv: fixture.commandDirectory + }) +} + +async function recoveryPath(commandDirectory: string): Promise { + const transactionName = (await readdir(commandDirectory)).find((name) => + name.startsWith('.orca-cli-') + ) + if (!transactionName) { + throw new Error('Expected a preserved CLI command transaction.') + } + return join(commandDirectory, transactionName, 'orca') +} + +async function rejectionFrom(operation: Promise): Promise { + try { + await operation + } catch (error) { + if (error instanceof Error) { + return error + } + throw error + } + throw new Error('Expected the operation to reject.') +} + +describe.skipIf(process.platform === 'win32')('CLI command filesystem races', () => { + it('replaces and removes an unchanged stale symlink through the real filesystem', async () => { + const fixture = await createMacCommandFixture() + await symlink(fixture.staleLauncherPath, fixture.commandPath) + const installer = createMacInstaller(fixture) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(fixture.commandPath)).resolves.toBe(fixture.launcherPath) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + await expect(lstat(fixture.commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('restores a foreign symlink whose quarantined inode identity is reused', async () => { + const fixture = await createMacCommandFixture() + const foreignTarget = join(fixture.root, 'foreign-command') + await symlink(fixture.staleLauncherPath, fixture.commandPath) + const original = await lstat(fixture.commandPath, { bigint: true }) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + }, + afterQuarantine: (quarantine) => { + if (quarantine.snapshot) { + reusedIdentity.path = quarantine.heldPath + reusedIdentity.dev = original.dev + reusedIdentity.ino = original.ino + quarantine.snapshot.identity = { + ...quarantine.snapshot.identity, + dev: original.dev, + ino: original.ino + } + } + } + }) + + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect( + (await readdir(fixture.commandDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('preserves a managed file changed in place after its final inspection', async () => { + const fixture = await createMacCommandFixture() + const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js') + await writeFile( + fixture.commandPath, + [ + '#!/usr/bin/env bash', + `CLI='${oldCliPath}'`, + 'export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}"', + 'export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"', + 'ELECTRON_RUN_AS_NODE=1 exec electron "$CLI" "$@"' + ].join('\n') + ) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await writeFile(commandPath, 'foreign command written into the inspected inode') + } + }) + + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readFile(fixture.commandPath, 'utf8')).resolves.toBe( + 'foreign command written into the inspected inode' + ) + }) + + it('restores a foreign symlink raced into command removal', async () => { + const fixture = await createMacCommandFixture() + const foreignTarget = join(fixture.root, 'foreign-command') + await symlink(fixture.launcherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + }) + + await expect(installer.remove()).rejects.toThrow('Refusing to remove non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + }) + + it('preserves a raced foreign directory at the reported recovery path', async () => { + const fixture = await createMacCommandFixture() + await symlink(fixture.staleLauncherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await mkdir(commandPath) + await writeFile(join(commandPath, 'user-data'), 'preserved') + } + }) + + const error = await rejectionFrom(installer.install()) + const heldPath = await recoveryPath(fixture.commandDirectory) + expect(error.message).toContain(heldPath) + await expect(readFile(join(heldPath, 'user-data'), 'utf8')).resolves.toBe('preserved') + await expect(lstat(fixture.commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves both entries when the original name is reclaimed during restoration', async () => { + const fixture = await createMacCommandFixture() + const contenderTarget = join(fixture.root, 'contender-command') + await symlink(fixture.staleLauncherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await writeFile(commandPath, 'foreign command') + }, + link: async (_heldPath, commandPath) => { + await symlink(contenderTarget, commandPath) + } + }) + + const error = await rejectionFrom(installer.install()) + const heldPath = await recoveryPath(fixture.commandDirectory) + expect(error.message).toContain(heldPath) + await expect(readFile(heldPath, 'utf8')).resolves.toBe('foreign command') + await expect(readlink(fixture.commandPath)).resolves.toBe(contenderTarget) + }) + + it('keeps a foreign legacy Linux command when readlink loses the inspection race', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-legacy-race-')) + createdRoots.push(root) + const homePath = join(root, 'home') + const commandDirectory = join(homePath, '.local', 'bin') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const legacyPath = join(commandDirectory, 'orca') + const managedLegacyTarget = join(resourcesPath, 'bin', 'orca') + const foreignTarget = join(root, 'foreign-orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await symlink(managedLegacyTarget, legacyPath) + + legacyReadlinkRace.commandPath = legacyPath + legacyReadlinkRace.replacementTarget = foreignTarget + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'orca-ide'), + appPath: join(root, 'resources', 'app.asar'), + homePath, + processPathEnv: commandDirectory + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(legacyPath)).resolves.toBe(foreignTarget) + }) + + it('keeps a foreign legacy Linux command whose quarantined inode is reused', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-legacy-identity-race-')) + createdRoots.push(root) + const homePath = join(root, 'home') + const commandDirectory = join(homePath, '.local', 'bin') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const legacyPath = join(commandDirectory, 'orca') + const managedTarget = join(resourcesPath, 'bin', 'orca') + const foreignTarget = join(root, 'foreign-orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await symlink(managedTarget, legacyPath) + const original = await lstat(legacyPath, { bigint: true }) + + class LegacyRaceInstaller extends CliInstaller { + protected override async quarantineCommandPath(commandPath: string) { + if (commandPath === legacyPath) { + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + const quarantine = await super.quarantineCommandPath(commandPath) + if (commandPath === legacyPath && quarantine.snapshot) { + reusedIdentity.path = quarantine.heldPath + reusedIdentity.dev = original.dev + reusedIdentity.ino = original.ino + quarantine.snapshot.identity = { + ...quarantine.snapshot.identity, + dev: original.dev, + ino: original.ino + } + } + return quarantine + } + } + + const installer = new LegacyRaceInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'orca-ide'), + appPath: join(root, 'resources', 'app.asar'), + homePath, + processPathEnv: commandDirectory + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(legacyPath)).resolves.toBe(foreignTarget) + }) +}) diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index a3f38778197..a3b5e9a0523 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -1,49 +1,101 @@ -import { lstat, mkdir, readlink, symlink, unlink, writeFile } from 'node:fs/promises' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { link, readlink, rmdir, symlink, unlink, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + ensureAppImageExtractedRoot, + isAppImageExtractedLauncherPath, + type AppImageExtractedRoot +} from './appimage-extracted-root' import { CliCommandInspection } from './cli-command-inspection' +import { + buildMacPrivilegedSymlinkTransaction, + capturedExpectedEntry, + hasSameIdentity, + hasSameSnapshot, + inspectStableCommand, + quarantineCommandPath, + readEntrySnapshot, + type CommandQuarantine, + type StableCommandInspection +} 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 { quoteShell } from './cli-install-path-format' +import { isPermissionError } from './cli-install-errors' +import { isPathInsideOrEqual } from './cli-install-path-format' export class CliCommandInstallation extends CliCommandInspection { protected async installSymlink(status: CliInstallStatus): Promise { + const commandPath = status.commandPath + const launcherPath = status.launcherPath + if (!commandPath || !launcherPath || status.state === 'installed') { + return + } + + const inspected = await this.inspectStableSymlink(commandPath, launcherPath) + if (inspected.status.state === 'conflict') { + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` + ) + } + if (inspected.status.state === 'installed') { + return + } + + let quarantine: CommandQuarantine try { - if (status.state === 'installed') { - return - } - if (status.state === 'stale') { - await unlink(status.commandPath as string) - } - // Why: mkdir stays here (not install()) so an EACCES falls into the privileged-runner catch below. - await mkdir(dirname(status.commandPath as string), { recursive: true }) - await symlink(status.launcherPath as string, status.commandPath as string) + quarantine = await this.quarantineCommandPath(commandPath) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { throw error } + await this.installSymlinkWithPrivileges(commandPath, launcherPath, inspected) + return + } - // Why: fall back to an elevated shell to place the /usr/local/bin symlink (VS Code-style) when direct write is denied. - await this.privilegedRunner( - `mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` + - `ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}` + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` ) } + + try { + await symlink(launcherPath, commandPath) + } catch (error) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw error + } + await this.discardQuarantinedCommand(quarantine) } protected async removeSymlink(commandPath: string): Promise { + const launcherPath = await this.resolveLauncherPath() + if (!launcherPath) { + throw new Error('The Orca CLI launcher is no longer available.') + } + const inspected = await this.inspectStableSymlink(commandPath, launcherPath) + if (inspected.status.state === 'not_installed') { + return + } + if (inspected.status.state === 'conflict') { + throw new Error(`Refusing to remove non-Orca command at ${commandPath}.`) + } + + let quarantine: CommandQuarantine try { - await unlink(commandPath) + quarantine = await this.quarantineCommandPath(commandPath) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { throw error } - await this.privilegedRunner( - `if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi` - ) + await this.removeSymlinkWithPrivileges(commandPath, inspected) + return } + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw new Error(`Refusing to remove non-Orca command at ${commandPath}.`) + } + await this.discardQuarantinedCommand(quarantine) } protected async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise { @@ -51,29 +103,35 @@ export class CliCommandInstallation extends CliCommandInspection { return } - const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) + const commandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) try { - const stats = await lstat(legacyCommandPath) - if (!stats.isSymbolicLink()) { + const inspected = await this.inspectStableLegacyCommand(commandPath, launcherPath) + if (!inspected?.managed) { return } - - const currentTarget = await readlink(legacyCommandPath) - const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget) - if (!this.isManagedLegacyLinuxTarget(resolvedCurrentTarget, launcherPath)) { + const quarantine = await this.quarantineCommandPath(commandPath) + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) return } - - // Why: after the Linux command rename, the old `orca` symlink would keep shadowing GNOME Orca. - await unlink(legacyCommandPath) + await this.discardQuarantinedCommand(quarantine) } catch (error) { - if (isMissingError(error)) { - return - } - throw error + // Why: the new command is already registered; leave legacy cleanup for a later attempt. + console.warn( + `[cli] Could not remove the legacy command at ${commandPath}:`, + error instanceof Error ? error.message : String(error) + ) } } + protected async quarantineCommandPath(commandPath: string): Promise { + return quarantineCommandPath(commandPath) + } + + protected async linkQuarantinedCommand(heldPath: string, commandPath: string): Promise { + await link(heldPath, commandPath) + } + protected isManagedLegacyLinuxTarget(resolvedTarget: string, launcherPath: string): boolean { const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME) if (resolvedTarget === legacyLauncherPath) { @@ -84,26 +142,174 @@ export class CliCommandInstallation extends CliCommandInspection { return false } - const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) - const devRelative = relative(devLauncherDir, resolvedTarget) - if (devRelative && !devRelative.startsWith('..') && !isAbsolute(devRelative)) { + if (this.isPackagedLinuxLauncherTarget(resolvedTarget, LEGACY_LINUX_COMMAND_NAME)) { return true } - // Why: AppImage upgrades can strand a legacy symlink into a now-gone FUSE mount that isn't a sibling of the stable path. - return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget) + const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) + if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) { + return true + } + + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? isAppImageExtractedLauncherPath( + extractionOptions, + resolvedTarget, + LEGACY_LINUX_COMMAND_NAME + ) + : false } protected async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') } - protected async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { - // Why: the AppImage command dir is user-writable, so create it before writing the wrapper. - await mkdir(dirname(commandPath), { recursive: true }) - await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { - encoding: 'utf8', - mode: 0o755 - }) + protected async ensureLinuxAppImagePayload(): Promise { + const extractionOptions = this.appImageExtractionOptions() + if (!this.isLinuxAppImage() || !extractionOptions) { + return null + } + const extractedRoot = await ensureAppImageExtractedRoot(extractionOptions) + if (!extractedRoot) { + throw new Error( + `Could not extract the Orca AppImage at ${this.appImagePath}. Check that it is executable and that ${this.appImageCacheRootPath} has free space.` + ) + } + return extractedRoot + } + + private async inspectStableSymlink( + commandPath: string, + launcherPath: string + ): Promise { + return inspectStableCommand(commandPath, () => this.inspectSymlink(commandPath, launcherPath)) + } + + private async inspectStableLegacyCommand( + commandPath: string, + launcherPath: string + ): Promise< + | (Pick & { + snapshot: NonNullable + managed: boolean + }) + | null + > { + 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))) + ) + } + } + + private async restoreQuarantinedCommand( + quarantine: CommandQuarantine, + commandPath: string + ): Promise { + if (!quarantine.snapshot) { + await rmdir(quarantine.directoryPath) + return + } + await this.assertHeldIdentity(quarantine) + try { + await (quarantine.snapshot.isSymbolicLink + ? symlink(await readlink(quarantine.heldPath), commandPath) + : this.linkQuarantinedCommand(quarantine.heldPath, commandPath)) + const restored = await readEntrySnapshot(commandPath) + const restoredSymlink = + restored?.isSymbolicLink && quarantine.snapshot.isSymbolicLink + ? (await readlink(commandPath)) === (await readlink(quarantine.heldPath)) + : false + if ( + !restored || + (!restoredSymlink && !hasSameIdentity(restored.identity, quarantine.snapshot.identity)) + ) { + throw new Error('The restored command identity could not be verified.') + } + await this.discardQuarantinedCommand(quarantine, quarantine.snapshot.isSymbolicLink) + } catch (error) { + throw new Error( + `The displaced entry is preserved at ${quarantine.heldPath}; ${commandPath} could not be restored without overwriting another entry.`, + { cause: error } + ) + } + } + + private async discardQuarantinedCommand( + quarantine: CommandQuarantine, + requireStableMetadata = true + ): Promise { + if (quarantine.snapshot) { + await this.assertHeldIdentity(quarantine, requireStableMetadata) + await unlink(quarantine.heldPath) + } + await rmdir(quarantine.directoryPath) + } + + private async assertHeldIdentity( + quarantine: CommandQuarantine, + requireStableMetadata = true + ): Promise { + const current = await readEntrySnapshot(quarantine.heldPath) + if ( + !current || + !quarantine.snapshot || + !(requireStableMetadata + ? hasSameSnapshot(current, quarantine.snapshot) + : hasSameIdentity(current.identity, quarantine.snapshot.identity)) + ) { + throw new Error(`The quarantined command changed at ${quarantine.heldPath}.`) + } + } + + private async installSymlinkWithPrivileges( + commandPath: string, + launcherPath: string, + inspected: StableCommandInspection + ): Promise { + await this.privilegedRunner( + buildMacPrivilegedSymlinkTransaction({ + action: 'install', + commandPath, + launcherPath, + expected: inspected.snapshot?.identity ?? null, + expectedFileSha256: inspected.fileSha256, + expectedRawSymlinkTarget: inspected.rawSymlinkTarget + }) + ) + const installed = await this.inspectStableSymlink(commandPath, launcherPath) + if (installed.status.state !== 'installed') { + throw new Error(`Could not register the Orca command at ${commandPath}.`) + } + } + + private async removeSymlinkWithPrivileges( + commandPath: string, + inspected: StableCommandInspection + ): Promise { + await this.privilegedRunner( + buildMacPrivilegedSymlinkTransaction({ + action: 'remove', + commandPath, + expected: inspected.snapshot?.identity ?? null, + expectedFileSha256: inspected.fileSha256, + expectedRawSymlinkTarget: inspected.rawSymlinkTarget + }) + ) } } diff --git a/src/main/cli/cli-command-privileged-transaction.test.ts b/src/main/cli/cli-command-privileged-transaction.test.ts new file mode 100644 index 00000000000..63349cb70c9 --- /dev/null +++ b/src/main/cli/cli-command-privileged-transaction.test.ts @@ -0,0 +1,189 @@ +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + readdir, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' +import { buildUnixDevLauncher } from './cli-dev-launcher' + +const createdRoots: string[] = [] +const protectedDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(protectedDirectories.splice(0).map((path) => chmod(path, 0o700))) + await Promise.all( + createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function createPrivilegedFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-privileged-transaction-')) + createdRoots.push(root) + const protectedDirectory = join(root, 'protected') + protectedDirectories.push(protectedDirectory) + const commandPath = join(protectedDirectory, 'orca') + const userDataPath = join(root, 'user-data') + const appPath = join(root, 'app') + await mkdir(protectedDirectory) + await mkdir(join(appPath, 'out', 'cli'), { recursive: true }) + await writeFile(join(appPath, 'out', 'cli', 'index.js'), 'console.log("orca")\n') + return { root, protectedDirectory, commandPath, userDataPath, appPath } +} + +async function executePrivilegedShell(command: string): Promise { + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + if (result.code !== 0) { + const error = new Error( + result.stderr || result.stdout || `Privileged shell exited ${result.code}.` + ) + Object.assign(error, { code: result.code, stderr: result.stderr }) + throw error + } +} + +function fixtureInstallerOptions(fixture: Awaited>) { + return { + platform: 'darwin' as const, + isPackaged: false, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + commandPathOverride: fixture.commandPath, + processPathEnv: fixture.protectedDirectory + } +} + +describe.skipIf(process.platform !== 'darwin' || process.getuid?.() === 0)( + 'macOS privileged CLI command transaction', + () => { + it('installs and removes through the generated no-overwrite shell transaction', async () => { + const fixture = await createPrivilegedFixture() + const commands: string[] = [] + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + commands.push(command) + await chmod(fixture.protectedDirectory, 0o700) + await executePrivilegedShell(command) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + const installed = await installer.install() + expect(installed.state).toBe('installed') + await expect(readlink(fixture.commandPath)).resolves.toBe(installed.launcherPath) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(commands).toHaveLength(2) + expect(commands.every((command) => command.includes('/bin/ln -P'))).toBe(true) + expect(commands.every((command) => !command.includes('mv -f'))).toBe(true) + }) + + it('restores a trailing-newline symlink inserted after privileged inspection', async () => { + const fixture = await createPrivilegedFixture() + const staleTarget = join(fixture.userDataPath, 'cli', 'bin', 'old', 'orca') + const foreignTarget = `${staleTarget}\n` + await symlink(staleTarget, fixture.commandPath) + const original = await lstat(fixture.commandPath, { bigint: true }) + let raced = false + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + if (!raced) { + raced = true + await unlink(fixture.commandPath) + await symlink(foreignTarget, fixture.commandPath) + } + const replacement = await lstat(fixture.commandPath, { bigint: true }) + await executePrivilegedShell( + command.replace( + `${original.dev}:${original.ino}`, + `${replacement.dev}:${replacement.ino}` + ) + ) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('restores a managed file changed in place after privileged inspection', async () => { + const fixture = await createPrivilegedFixture() + const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js') + await writeFile( + fixture.commandPath, + buildUnixDevLauncher('/Applications/Old.app/Contents/MacOS/Orca', oldCliPath, 'user-data') + ) + const foreignContent = 'foreign command written into the inspected inode' + let raced = false + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + if (!raced) { + raced = true + await writeFile(fixture.commandPath, foreignContent) + } + await executePrivilegedShell(command) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readFile(fixture.commandPath, 'utf8')).resolves.toBe(foreignContent) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('restores the displaced command when publication setup fails', async () => { + const fixture = await createPrivilegedFixture() + const staleTarget = join(fixture.userDataPath, 'cli', 'bin', 'old', 'orca') + await symlink(staleTarget, fixture.commandPath) + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + const sabotaged = command.replace(/\/bin\/mkdir ('[^']*\/publish')/, '/usr/bin/false') + expect(sabotaged).not.toBe(command) + await executePrivilegedShell(sabotaged) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readlink(fixture.commandPath)).resolves.toBe(staleTarget) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + } +) diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts index 0d0321df34a..228e574addc 100644 --- a/src/main/cli/cli-install-location.ts +++ b/src/main/cli/cli-install-location.ts @@ -3,6 +3,16 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + getAppImageCacheRootPath, + resolveAppImageExtractedRoot, + type AppImageExtractionOptions +} from './appimage-extracted-root' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { DEFAULT_MAC_COMMAND_PATH, DEV_COMMAND_NAME } from './cli-install-constants' import { ensureDevLauncher } from './cli-dev-launcher' import type { CliInstallerOptions, InstallSpec } from './cli-installer-contracts' @@ -13,7 +23,6 @@ import { uniquePathEntries } from './cli-install-path-format' import { runMacPrivilegedCommand, writeWindowsUserPath } from './cli-privileged-processes' -import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { invalidateWindowsUserPathRegistryCache, readFreshWindowsUserPathRegistry, @@ -46,6 +55,9 @@ export abstract class CliInstallLocation { protected readonly userPathCacheInvalidator: () => void protected readonly windowsEnvironment: NodeJS.ProcessEnv protected readonly appImagePath: string | null + protected readonly hasUnverifiedAppImageRuntime: boolean + protected readonly appImageCacheRootPath: string + protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise protected get commandName(): string { if (!this.isPackaged && !this.commandPathOverride) { @@ -84,10 +96,27 @@ export abstract class CliInstallLocation { this.userPathCacheInvalidator = options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache this.windowsEnvironment = options.windowsEnvironment ?? process.env + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeAppImageIdentity = resolveAppImageRuntimeIdentity({ + platform: this.platform, + execPath: this.execPathValue, + resourcesPath: this.resourcesPath + }) + this.hasUnverifiedAppImageRuntime = + this.platform === 'linux' && + this.isPackaged && + !hasExplicitAppImagePath && + hasAppImagePathEnvironment() && + !runtimeAppImageIdentity this.appImagePath = this.platform === 'linux' && this.isPackaged - ? (options.appImagePath ?? process.env.APPIMAGE ?? null) + ? hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeAppImageIdentity?.appImagePath ?? null) : null + this.appImageCacheRootPath = + options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath) + this.appImageExtractRunner = options.appImageExtractRunner } protected resolveInstallSpec(): InstallSpec | null { @@ -99,7 +128,7 @@ export abstract class CliInstallLocation { if (this.platform === 'darwin' || this.platform === 'linux') { return { commandPath, - installMethod: this.isLinuxAppImage() ? 'wrapper' : 'symlink' + installMethod: 'symlink' } } @@ -212,8 +241,18 @@ export abstract class CliInstallLocation { return null } + if (this.hasUnverifiedAppImageRuntime) { + return null + } + if (this.isLinuxAppImage()) { - return this.appImagePath && existsSync(this.appImagePath) ? this.appImagePath : null + if (!this.appImagePath || !existsSync(this.appImagePath)) { + return null + } + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? (resolveAppImageExtractedRoot(extractionOptions)?.stableLauncherPath ?? null) + : null } if (this.isPackaged) { @@ -229,4 +268,14 @@ export abstract class CliInstallLocation { commandName: this.commandName }) } + + protected appImageExtractionOptions(): AppImageExtractionOptions | null { + return this.appImagePath + ? { + appImagePath: this.appImagePath, + cacheRootPath: this.appImageCacheRootPath, + runExtract: this.appImageExtractRunner + } + : null + } } diff --git a/src/main/cli/cli-installer-appimage-ownership.test.ts b/src/main/cli/cli-installer-appimage-ownership.test.ts new file mode 100644 index 00000000000..f3b5107c331 --- /dev/null +++ b/src/main/cli/cli-installer-appimage-ownership.test.ts @@ -0,0 +1,283 @@ +import { + lstat, + mkdir, + mkdtemp, + readlink, + readdir, + rename, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { resolveAppImageExtractedRoot, type AppImageExtractedRoot } from './appimage-extracted-root' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' + +const created: string[] = [] + +type Fixture = Awaited> + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(created.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-appimage-ownership-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + const cacheRootPath = join(root, 'cache') + const commandDirectory = join(root, 'home', '.local', 'bin') + const commandPath = join(commandDirectory, 'orca-ide') + await mkdir(commandDirectory, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath, commandDirectory, commandPath } +} + +async function extractPayload(_appImagePath: string, cwd: string): Promise { + const launcherDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDirectory, { recursive: true }) + await writeFile(join(launcherDirectory, 'orca-ide'), '#!/usr/bin/env bash\n', { mode: 0o755 }) +} + +function installerOptions(fixture: Fixture) { + return { + platform: 'linux' as const, + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: join(fixture.root, 'mount', 'resources'), + execPath: join(fixture.root, 'mount', 'orca-ide'), + appPath: join(fixture.root, 'mount', 'resources', 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + appImagePath: fixture.appImagePath, + appImageCacheRootPath: fixture.cacheRootPath, + appImageExtractRunner: extractPayload + } +} + +describe.skipIf(process.platform === 'win32')('AppImage CLI ownership', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'mounted', 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', dirname(resourcesPath)) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath, + execPath: join(dirname(resourcesPath), 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + commandPathOverride: fixture.commandPath + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'not_installed', + launcherPath + }) + await expect(installer.install()).resolves.toMatchObject({ + state: 'installed', + launcherPath + }) + await expect(readlink(fixture.commandPath)).resolves.toBe(launcherPath) + }) + + it('ignores inherited APPIMAGE without the matching runtime identity', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'installed', 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + vi.stubEnv('APPIMAGE', fixture.appImagePath) + vi.stubEnv('APPDIR', '') + const extract = vi.fn(extractPayload) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath, + execPath: join(fixture.root, 'installed', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + appImageCacheRootPath: fixture.cacheRootPath, + appImageExtractRunner: extract + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'unsupported', + launcherPath: null, + detail: expect.stringContaining('could not verify') + }) + await expect(installer.install()).rejects.toThrow('could not verify') + expect(extract).not.toHaveBeenCalled() + }) + + it('refuses an arbitrary resources/bin/orca-ide symlink', async () => { + const fixture = await makeFixture() + const foreignTarget = join(fixture.root, 'foreign', 'resources', 'bin', 'orca-ide') + await symlink(foreignTarget, fixture.commandPath) + const extract = vi.fn(extractPayload) + const installer = new CliInstaller({ + ...installerOptions(fixture), + appImageExtractRunner: extract + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'conflict' }) + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect(extract).not.toHaveBeenCalled() + }) + + it('leaves a foreign legacy resources/bin/orca symlink untouched', async () => { + const fixture = await makeFixture() + const legacyCommandPath = join(fixture.commandDirectory, 'orca') + const foreignTarget = join(fixture.root, 'foreign', 'resources', 'bin', 'orca') + await symlink(foreignTarget, legacyCommandPath) + + await expect(new CliInstaller(installerOptions(fixture)).install()).resolves.toMatchObject({ + state: 'installed' + }) + await expect(readlink(legacyCommandPath)).resolves.toBe(foreignTarget) + }) + + it('keeps installation bound to the extracted generation', async () => { + const fixture = await makeFixture() + let extractedRoot: AppImageExtractedRoot | null = null + class ReplacingAppImageInstaller extends CliInstaller { + protected override async ensureLinuxAppImagePayload(): Promise { + extractedRoot = await super.ensureLinuxAppImagePayload() + await writeFile(fixture.appImagePath, '#!/usr/bin/env bash\n# replacement generation\n', { + mode: 0o755 + }) + return extractedRoot + } + } + + const installer = new ReplacingAppImageInstaller(installerOptions(fixture)) + const installed = await installer.install() + const capturedRoot = extractedRoot as AppImageExtractedRoot | null + + expect(capturedRoot).not.toBeNull() + expect(installed).toMatchObject({ + state: 'stale', + launcherPath: capturedRoot!.stableLauncherPath + }) + await expect(readlink(fixture.commandPath)).resolves.toBe(capturedRoot!.stableLauncherPath) + await expect(lstat(capturedRoot!.stableLauncherPath)).resolves.toBeDefined() + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale' }) + }) + + it('repairs the stable endpoint without reclaiming the prior path owner', async () => { + const fixture = await makeFixture() + const firstInstaller = new CliInstaller(installerOptions(fixture)) + const first = await firstInstaller.install() + const firstRoot = resolveAppImageExtractedRoot({ + appImagePath: fixture.appImagePath, + cacheRootPath: fixture.cacheRootPath + })! + const relocatedPath = join(fixture.root, 'downloads', 'Orca.AppImage') + await mkdir(dirname(relocatedPath), { recursive: true }) + await rename(fixture.appImagePath, relocatedPath) + const relocatedFixture = { ...fixture, appImagePath: relocatedPath } + const relocatedInstaller = new CliInstaller(installerOptions(relocatedFixture)) + + await expect(relocatedInstaller.getStatus()).resolves.toMatchObject({ state: 'stale' }) + const repaired = await relocatedInstaller.install() + const relocatedRoot = resolveAppImageExtractedRoot({ + appImagePath: relocatedPath, + cacheRootPath: fixture.cacheRootPath + })! + + expect(repaired).toMatchObject({ state: 'installed', launcherPath: first.launcherPath }) + await expect(readlink(fixture.commandPath)).resolves.toBe(first.launcherPath) + await expect(lstat(relocatedRoot.payloadLauncherPath)).resolves.toBeDefined() + // A path namespace is an ownership boundary; the relocated process cannot prove that no + // sibling AppImage still owns the prior namespace. + await expect(lstat(firstRoot.rootPath)).resolves.toBeDefined() + }) + + it('preserves a foreign command that appears at the final ownership fence', async () => { + const fixture = await makeFixture() + const predictedRoot = resolveAppImageExtractedRoot({ + appImagePath: fixture.appImagePath, + cacheRootPath: fixture.cacheRootPath + })! + const ownedOldTarget = join( + dirname(predictedRoot.rootPath), + 'a'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + const foreignTarget = join(fixture.root, 'foreign', 'orca-ide') + await symlink(ownedOldTarget, fixture.commandPath) + + class RacedInstaller extends CliInstaller { + private inspectionCount = 0 + + protected override async inspectSymlink( + commandPath: string, + launcherPath: string + ): Promise { + this.inspectionCount += 1 + if (this.inspectionCount === 3) { + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + return super.inspectSymlink(commandPath, launcherPath) + } + } + + await expect(new RacedInstaller(installerOptions(fixture)).install()).rejects.toThrow( + 'Refusing to replace non-Orca command' + ) + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect((await readdir(fixture.commandDirectory)).some((name) => name.includes('.orca-'))).toBe( + false + ) + }) + + // #15081 review: the Linux reclaim rule was narrowed to extracted-cache launchers, which left a + // deb/rpm -> AppImage migration wedged on its own leftover symlink. + it('reclaims a symlink left by a packaged deb/rpm install', async () => { + for (const directory of ['/opt/Orca', '/opt/orca-ide', '/opt/orca']) { + const fixture = await makeFixture() + await symlink(`${directory}/resources/bin/orca-ide`, fixture.commandPath) + + await expect(new CliInstaller(installerOptions(fixture)).getStatus()).resolves.toMatchObject({ + state: 'stale' + }) + } + }) + + it('still refuses a launcher-named symlink outside the packaged install tree', async () => { + const fixture = await makeFixture() + await symlink('/opt/not-orca/resources/bin/orca-ide', fixture.commandPath) + + await expect(new CliInstaller(installerOptions(fixture)).getStatus()).resolves.toMatchObject({ + state: 'conflict' + }) + }) +}) diff --git a/src/main/cli/cli-installer-appimage-removal.test.ts b/src/main/cli/cli-installer-appimage-removal.test.ts new file mode 100644 index 00000000000..9483141184c --- /dev/null +++ b/src/main/cli/cli-installer-appimage-removal.test.ts @@ -0,0 +1,192 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getPath: () => tmpdir(), getAppPath: () => tmpdir() } +})) + +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' +import { CliInstaller } from './cli-installer' +import type { CliInstallerOptions } from './cli-installer-contracts' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { + it.each([false, true])( + 'removes installed payloads and the legacy live endpoint (command missing: %s)', + async (removeCommandFirst) => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-remove-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + const cacheRootPath = join(root, 'cache') + const commandPath = join(root, 'home', '.local', 'bin', 'orca-ide') + const resourcesPath = join(root, 'mount', 'resources') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await writeFile(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', { mode: 0o755 }) + const liveEndpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + await mkdir(dirname(liveEndpointPath), { recursive: true }) + await symlink(liveLauncherPath, liveEndpointPath) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'mount', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(root, 'home'), + processPathEnv: join(root, 'home', '.local', 'bin'), + commandPathOverride: commandPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + const payloadDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(payloadDirectory, { recursive: true }) + await writeFile( + join(payloadDirectory, 'orca-ide'), + '#!/usr/bin/env bash\nprintf installed', + { + mode: 0o755 + } + ) + } + }) + + const installed = await installer.install() + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(await readlink(commandPath)).toBe(installed.launcherPath) + expect(existsSync(extractedRoot.rootPath)).toBe(true) + if (removeCommandFirst) { + await unlink(commandPath) + } + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + + expect(existsSync(commandPath)).toBe(false) + expect(existsSync(extractedRoot.rootPath)).toBe(false) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe( + false + ) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + const stableLauncherPath = resolveAppImageStableLauncherPath(cacheRootPath) + expect(existsSync(stableLauncherPath)).toBe(true) + } + ) + + it('does not remove a sibling AppImage registration or payload', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-siblings-')) + created.push(root) + const cacheRootPath = join(root, 'cache') + const commandPath = join(root, 'home', '.local', 'bin', 'orca-ide') + const resourcesPath = join(root, 'mount', 'resources') + const firstAppImagePath = join(root, 'Orca-stable.AppImage') + const secondAppImagePath = join(root, 'Orca-nightly.AppImage') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await Promise.all([ + writeFile(firstAppImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }), + writeFile(secondAppImagePath, '#!/usr/bin/env bash\n# nightly\n', { mode: 0o755 }) + ]) + const installerOptions = (appImagePath: string, content: string): CliInstallerOptions => ({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'mount', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(root, 'home'), + processPathEnv: join(root, 'home', '.local', 'bin'), + commandPathOverride: commandPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + const payloadDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(payloadDirectory, { recursive: true }) + await writeFile(join(payloadDirectory, 'orca-ide'), content, { mode: 0o755 }) + } + }) + class HookedInstaller extends CliInstaller { + afterNextStatus: (() => Promise) | null = null + + override async getStatus() { + const status = await super.getStatus() + const hook = this.afterNextStatus + this.afterNextStatus = null + await hook?.() + return status + } + } + let siblingStatusReads = 0 + let rejectSiblingStatusRead = false + class TrackingInstaller extends CliInstaller { + override async getStatus() { + if (rejectSiblingStatusRead) { + throw new Error('sibling status read before registration lock release') + } + siblingStatusReads += 1 + return super.getStatus() + } + } + const firstInstaller = new HookedInstaller(installerOptions(firstAppImagePath, 'stable')) + const secondInstaller = new TrackingInstaller(installerOptions(secondAppImagePath, 'nightly')) + + await firstInstaller.install() + const firstRoot = resolveAppImageExtractedRoot({ + appImagePath: firstAppImagePath, + cacheRootPath + })! + await secondInstaller.install() + const secondRoot = resolveAppImageExtractedRoot({ + appImagePath: secondAppImagePath, + cacheRootPath + })! + + const siblingStatus = await firstInstaller.getStatus() + expect(firstInstaller.isAppImageRegistrationOwnedBySibling(siblingStatus)).toBe(true) + await rm(resolveAppImageStableLauncherPath(cacheRootPath)) + const brokenLauncherStatus = await firstInstaller.getStatus() + expect(firstInstaller.isAppImageRegistrationOwnedBySibling(brokenLauncherStatus)).toBe(false) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', secondRoot.payloadLauncherPath) + + await firstInstaller.remove() + + expect(existsSync(firstRoot.rootPath)).toBe(false) + expect(existsSync(secondRoot.rootPath)).toBe(true) + expect(existsSync(commandPath)).toBe(true) + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(secondRoot.payloadLauncherPath) + + await firstInstaller.install() + let siblingInstall: Promise | null = null + siblingStatusReads = 0 + firstInstaller.afterNextStatus = async () => { + rejectSiblingStatusRead = true + siblingInstall = secondInstaller.install() + } + await firstInstaller.remove() + rejectSiblingStatusRead = false + expect(siblingInstall).not.toBeNull() + await siblingInstall + + expect(siblingStatusReads).toBeGreaterThan(0) + expect(existsSync(commandPath)).toBe(true) + expect(existsSync(secondRoot.rootPath)).toBe(true) + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(secondRoot.payloadLauncherPath) + }) +}) diff --git a/src/main/cli/cli-installer-contracts.ts b/src/main/cli/cli-installer-contracts.ts index 5bb3bb99d7e..40a75cb984f 100644 --- a/src/main/cli/cli-installer-contracts.ts +++ b/src/main/cli/cli-installer-contracts.ts @@ -20,8 +20,10 @@ export type CliInstallerOptions = { userPathWriter?: (value: string) => Promise userPathCacheInvalidator?: () => void windowsEnvironment?: NodeJS.ProcessEnv - /** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */ + /** Trusted caller override; production discovers AppImage only from a complete runtime identity. */ appImagePath?: string | null + appImageCacheRootPath?: string + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } export type InstallSpec = { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 7a2e86afb24..51d5cf05e35 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1,6 +1,17 @@ -import { chmod, lstat, mkdir, readFile, readlink, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { + chmod, + lstat, + mkdir, + readFile, + readdir, + readlink, + rm, + symlink, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, relative, sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const execFileMock = vi.hoisted(() => vi.fn()) @@ -18,8 +29,20 @@ vi.mock('node:child_process', () => ({ })) import { CliInstaller } from './cli-installer' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { makeFixture } from './cli-installer-test-fixtures' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { buildLegacyAppImageCliWrapper } from './legacy-appimage-cli-wrapper' + +// Stands in for the AppImage runtime's `--appimage-extract`, which writes the +// payload to ./squashfs-root relative to cwd. +async function fakeAppImageExtractRunner(_appImagePath: string, cwd: string): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) +} describe('CliInstaller', () => { beforeEach(() => { @@ -127,15 +150,18 @@ describe('CliInstaller', () => { } ) - // Why: AppImage resources live under a per-launch FUSE mount, so the - // installed shell command must be a stable wrapper rather than a symlink. + // Why: an AppImage's payload is only reachable through a FUSE mount that its + // own AppRun sets up, and AppRun prepends `--no-sandbox` into node mode on + // userns-restricted hosts (#11609). Extracting once gives the command the + // same plain launcher a deb install ships, so registration is a symlink. it.skipIf(process.platform === 'win32')( - 'creates an AppImage wrapper under the linux command path', + 'symlinks the linux command at the extracted AppImage launcher', async () => { const fixture = await makeFixture() const commandDir = join(fixture.root, '.local', 'bin') const installPath = join(commandDir, 'orca-ide') const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 @@ -144,77 +170,134 @@ describe('CliInstaller', () => { const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, commandPathOverride: installPath, processPathEnv: commandDir }) const initial = await installer.getStatus() - expect(initial).toMatchObject({ - state: 'not_installed', - installMethod: 'wrapper', - launcherPath: appImagePath - }) + expect(initial).toMatchObject({ state: 'not_installed', installMethod: 'symlink' }) const installed = await installer.install() expect(installed).toMatchObject({ state: 'installed', commandName: 'orca-ide', - installMethod: 'wrapper', - launcherPath: appImagePath, - currentTarget: appImagePath, + installMethod: 'symlink', pathConfigured: true }) + // The command target remains stable while its cache endpoint advances generations. + expect(relative(cacheRootPath, installed.launcherPath as string).split(sep)).toEqual([ + 'launcher', + 'orca-ide' + ]) + expect(installed.currentTarget).toBe(installed.launcherPath) + await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) - const commandStats = await lstat(installPath) - expect(commandStats.isFile()).toBe(true) - expect(commandStats.mode & 0o111).not.toBe(0) - await expect(readlink(installPath)).rejects.toMatchObject({ code: 'EINVAL' }) - await expect(readFile(installPath, 'utf8')).resolves.toBe( - buildAppImageCliWrapper(appImagePath) - ) + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await rm(extractedRoot.rootPath, { recursive: true, force: true }) + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale' }) + const repaired = await installer.install() + expect(repaired.state).toBe('installed') const removed = await installer.remove() expect(removed.state).toBe('not_installed') + await expect(lstat(repaired.launcherPath as string)).resolves.toBeDefined() + expect( + existsSync(resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!.rootPath) + ).toBe(false) } ) it.skipIf(process.platform === 'win32')( - 'reports a stale AppImage wrapper when the AppImage path changes', + 're-extracts and re-points the command when the AppImage is replaced', async () => { const fixture = await makeFixture() const commandDir = join(fixture.root, '.local', 'bin') const installPath = join(commandDir, 'orca-ide') - const oldAppImagePath = join(fixture.root, 'Old-Orca.AppImage') - const newAppImagePath = join(fixture.root, 'Orca.AppImage') - await mkdir(commandDir, { recursive: true }) - await writeFile(installPath, buildAppImageCliWrapper(oldAppImagePath), { + const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - await writeFile(newAppImagePath, '#!/usr/bin/env bash\n', { + const makeInstaller = (): CliInstaller => + new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + const first = await makeInstaller().install() + expect(first.state).toBe('installed') + + // An update replaces the file in place, so size and mtime both change. + await writeFile(appImagePath, '#!/usr/bin/env bash\n# next version\n', { encoding: 'utf8', mode: 0o755 }) + await expect(makeInstaller().getStatus()).resolves.toMatchObject({ state: 'stale' }) + + const second = await makeInstaller().install() + expect(second.state).toBe('installed') + expect(second.launcherPath).toBe(first.launcherPath) + await expect(readlink(installPath)).resolves.toBe(second.launcherPath) + // Only the live payload survives the upgrade. + const liveRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!.rootPath + await expect(readdir(dirname(liveRoot))).resolves.toHaveLength(1) + } + ) + + it.skipIf(process.platform === 'win32')( + 'replaces and removes the legacy AppImage wrapper', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, '.local', 'bin') + const installPath = join(commandDir, 'orca-ide') + const appImagePath = join(fixture.root, "Orca's AppImage.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(installPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) const installer = new CliInstaller({ platform: 'linux', isPackaged: true, - appImagePath: newAppImagePath, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, commandPathOverride: installPath, processPathEnv: commandDir }) await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale', - installMethod: 'wrapper', - currentTarget: newAppImagePath + currentTarget: appImagePath }) - await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) - await expect(readFile(installPath, 'utf8')).resolves.toBe( - buildAppImageCliWrapper(newAppImagePath) - ) + await expect(readlink(installPath)).resolves.toContain(cacheRootPath) + + await installer.remove() + await writeFile(installPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + await expect(lstat(installPath)).rejects.toMatchObject({ code: 'ENOENT' }) } ) @@ -239,6 +322,8 @@ describe('CliInstaller', () => { const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, resourcesPath, homePath, processPathEnv: commandDir @@ -251,24 +336,30 @@ describe('CliInstaller', () => { ) it.skipIf(process.platform === 'win32')( - 'removes a legacy linux orca symlink when installing an AppImage wrapper', + 'removes a legacy linux orca symlink when registering from an 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 cacheRootPath = join(fixture.root, 'cache') await mkdir(commandDir, { recursive: true }) await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - await symlink(join('/tmp', '.mount_Orca1234', 'resources', 'bin', 'orca'), legacyCommandPath) + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await symlink(join(dirname(extractedRoot.payloadLauncherPath), 'orca'), legacyCommandPath) const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, homePath, processPathEnv: commandDir }) @@ -279,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)( @@ -315,7 +456,9 @@ describe('CliInstaller', () => { expect(installed.pathConfigured).toBe(true) expect(privilegedCommands).toHaveLength(1) expect(privilegedCommands[0]).toContain('mkdir -p') - expect(privilegedCommands[0]).toContain('ln -sfn') + expect(privilegedCommands[0]).toContain('ln -s') + expect(privilegedCommands[0]).toContain('/bin/ln -P') + expect(privilegedCommands[0]).not.toContain('mv -f') await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) } finally { await chmod(protectedDir, 0o700).catch(() => undefined) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index bc1f8c452b2..d95e1649ac0 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -1,10 +1,33 @@ import { mkdir, unlink } from 'node:fs/promises' import { dirname } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' -import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { + pruneAppImageExtractedRoots, + removeAppImageInstalledPayloads +} from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { + isAppImageInstalledLauncherCurrent, + isAppImageInstalledLauncherOwnedBySibling, + resolveAppImageNamespacePath +} from './appimage-extracted-root' +import { isAppImageStableLauncherReady } from './appimage-stable-launcher' import { CliPathRegistration } from './cli-path-registration' export class CliInstaller extends CliPathRegistration { + isAppImageRegistrationOwnedBySibling(status: CliInstallStatus): boolean { + if ( + status.currentTarget !== status.launcherPath || + !isAppImageStableLauncherReady(this.appImageCacheRootPath) + ) { + return false + } + const extractionOptions = this.appImageExtractionOptions() + return Boolean( + extractionOptions && isAppImageInstalledLauncherOwnedBySibling(extractionOptions) + ) + } + async getStatus(): Promise { const defaultSpec = this.resolveInstallSpec() if (!defaultSpec) { @@ -26,8 +49,9 @@ export class CliInstaller extends CliPathRegistration { const launcherPath = await this.resolveLauncherPath() if (!launcherPath) { - const detail = - this.isLinuxAppImage() && this.appImagePath + const detail = this.hasUnverifiedAppImageRuntime + ? 'Orca could not verify the inherited AppImage runtime identity, so CLI registration is unavailable.' + : this.isLinuxAppImage() && this.appImagePath ? `The AppImage file at ${this.appImagePath} is missing. Move it back or re-run CLI registration from the current AppImage location.` : this.isPackaged ? 'The bundled CLI launcher is missing from this Orca build.' @@ -48,34 +72,71 @@ export class CliInstaller extends CliPathRegistration { } } + return this.getStatusForLauncher(launcherPath) + } + + private async getStatusForLauncher(launcherPath: string): Promise { + const defaultSpec = this.resolveInstallSpec() + if (!defaultSpec) { + throw new Error('CLI registration is not implemented on this platform.') + } const spec = await this.resolveActiveInstallSpec(defaultSpec, launcherPath) - const baseStatus = + const inspectedStatus = spec.installMethod === 'symlink' ? await this.inspectSymlink(spec.commandPath, launcherPath) - : this.isLinuxAppImage() - ? await this.inspectAppImageWrapper(spec.commandPath, launcherPath) - : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + const extractionOptions = this.appImageExtractionOptions() + const baseStatus = + inspectedStatus.state === 'installed' && + extractionOptions && + !isAppImageInstalledLauncherCurrent(extractionOptions) + ? { + ...inspectedStatus, + state: 'stale' as const, + detail: `${spec.commandPath} does not point to the current Orca AppImage payload.` + } + : inspectedStatus const pathDirectory = dirname(spec.commandPath) const pathProbe = await this.probePathConfiguration(pathDirectory) return this.withPathInfo(baseStatus, pathDirectory, pathProbe) } async install(): Promise { - const status = await this.getStatus() + return this.runAppImageRegistrationOperation(() => this.installUnlocked()) + } + + private async installUnlocked(): Promise { + const initialStatus = await this.getStatus() + if ( + !initialStatus.supported || + !initialStatus.commandPath || + !initialStatus.launcherPath || + !initialStatus.installMethod + ) { + throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.') + } + if (initialStatus.state === 'conflict') { + throw new Error( + `Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.` + ) + } + const extractedRoot = await this.ensureLinuxAppImagePayload() + const status = extractedRoot + ? await this.getStatusForLauncher(extractedRoot.stableLauncherPath) + : initialStatus if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { throw new Error(status.detail ?? 'CLI registration is unavailable on this build.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. if (status.installMethod === 'symlink') { await this.installSymlink(status) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) - } else if (this.isLinuxAppImage()) { - await this.installAppImageWrapper(status.commandPath, status.launcherPath) - await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { // Why: packaged Windows already ships resources/bin/orca.exe; registration only owns the PATH entry. } else { @@ -88,13 +149,23 @@ export class CliInstaller extends CliPathRegistration { // Why: Windows shells find commands via user PATH, so the installer owns that entry, not the desktop installer. await this.ensureWindowsPathEntry(dirname(status.commandPath)) } + if (extractedRoot) { + await pruneAppImageExtractedRoots(extractedRoot.rootPath) + } - return this.getStatus() + return extractedRoot + ? this.getStatusForLauncher(extractedRoot.stableLauncherPath) + : this.getStatus() } async remove(): Promise { + return this.runAppImageRegistrationOperation(() => this.removeUnlocked()) + } + + private async removeUnlocked(): Promise { const status = await this.getStatus() if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { + await this.removeLinuxAppImagePayloads() return status } if (status.state === 'not_installed') { @@ -103,15 +174,21 @@ export class CliInstaller extends CliPathRegistration { await this.removeWindowsPathEntry(dirname(status.commandPath)) return this.getStatus() } + await this.removeLinuxAppImagePayloads() return status } if (status.state === 'conflict') { throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`) } - if (status.state === 'stale') { + if (status.state === 'stale' && status.installMethod !== 'symlink') { throw new Error(`Refusing to remove a command not owned by Orca at ${status.commandPath}.`) } + if (status.state === 'stale' && this.isAppImageRegistrationOwnedBySibling(status)) { + await this.removeLinuxAppImagePayloads() + return this.getStatus() + } + if (status.installMethod === 'symlink') { await this.removeSymlink(status.commandPath) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) @@ -122,8 +199,20 @@ export class CliInstaller extends CliPathRegistration { await this.removeWindowsPathEntry(dirname(status.commandPath)) } + await this.removeLinuxAppImagePayloads() return this.getStatus() } -} -export { getBundledLauncherPath } + private async removeLinuxAppImagePayloads(): Promise { + const extractionOptions = this.appImageExtractionOptions() + if (this.isLinuxAppImage() && extractionOptions) { + await removeAppImageInstalledPayloads(resolveAppImageNamespacePath(extractionOptions)) + } + } + + private runAppImageRegistrationOperation(operation: () => Promise): Promise { + return this.isLinuxAppImage() + ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation) + : operation() + } +} diff --git a/src/main/cli/legacy-appimage-cli-wrapper.test.ts b/src/main/cli/legacy-appimage-cli-wrapper.test.ts new file mode 100644 index 00000000000..5c4a64f8f3a --- /dev/null +++ b/src/main/cli/legacy-appimage-cli-wrapper.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + buildLegacyAppImageCliWrapper, + extractLegacyAppImageCliWrapperTarget +} from './legacy-appimage-cli-wrapper' + +describe('legacy AppImage CLI wrapper', () => { + it('recovers a path containing a newline', () => { + const appImagePath = "/tmp/Orca\nnightly's.AppImage" + expect(extractLegacyAppImageCliWrapperTarget(buildLegacyAppImageCliWrapper(appImagePath))).toBe( + appImagePath + ) + }) + + it('rejects a wrapper with a changed command body', () => { + const wrapper = buildLegacyAppImageCliWrapper('/tmp/Orca.AppImage') + expect( + extractLegacyAppImageCliWrapperTarget(wrapper.replace('set -euo pipefail', 'set -u')) + ).toBe(null) + }) +}) diff --git a/src/main/cli/appimage-cli-wrapper.ts b/src/main/cli/legacy-appimage-cli-wrapper.ts similarity index 59% rename from src/main/cli/appimage-cli-wrapper.ts rename to src/main/cli/legacy-appimage-cli-wrapper.ts index f66ecacfec2..1e22b2dc577 100644 --- a/src/main/cli/appimage-cli-wrapper.ts +++ b/src/main/cli/legacy-appimage-cli-wrapper.ts @@ -1,3 +1,5 @@ +import { quoteShell } from './cli-install-path-format' + const APPIMAGE_CLI_SCRIPT = [ '(async()=>{', 'try{', @@ -12,9 +14,15 @@ const APPIMAGE_CLI_SCRIPT = [ '})();' ].join('') -export function buildAppImageCliWrapper(appImagePath: string): string { - // Why: AppImage mounts resources under a fresh FUSE path per launch, so the - // installed command must call the stable outer AppImage and resolve APPDIR. +export function extractLegacyAppImageCliWrapperTarget(content: string): string | null { + const assignment = /^APPIMAGE=([\s\S]+?)\nif \[ ! -f "\$APPIMAGE" \]; then/mu.exec(content)?.[1] + const appImagePath = assignment ? unquoteShell(assignment) : null + return appImagePath && content === buildLegacyAppImageCliWrapper(appImagePath) + ? appImagePath + : null +} + +export function buildLegacyAppImageCliWrapper(appImagePath: string): string { return `#!/usr/bin/env bash set -euo pipefail APPIMAGE=${quoteShell(appImagePath)} @@ -32,6 +40,10 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} -- ` } -export function quoteShell(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'` +function unquoteShell(value: string): string | null { + if (!value.startsWith("'") || !value.endsWith("'")) { + return null + } + const decoded = value.slice(1, -1).split(`'"'"'`).join("'") + return quoteShell(decoded) === value ? decoded : null } diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts index b2bb40e558b..b8031535134 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.test.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -1,13 +1,63 @@ +import { existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +const { filePublicationFailures } = vi.hoisted(() => ({ + filePublicationFailures: { link: 0, replaceBeforeRename: '' } +})) +const registrationLock = vi.hoisted(() => ({ + completed: null as ((cacheRootPath: string) => void) | null, + entered: null as ((cacheRootPath: string) => void) | null, + pause: null as Promise | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + link: async (...args: Parameters) => { + if (filePublicationFailures.link > 0) { + filePublicationFailures.link -= 1 + throw Object.assign(new Error('link unsupported'), { code: 'ENOTSUP' }) + } + return actual.link(...args) + }, + rename: async (...args: Parameters) => { + if (filePublicationFailures.replaceBeforeRename) { + await actual.writeFile(args[0], filePublicationFailures.replaceBeforeRename, { + mode: 0o755 + }) + filePublicationFailures.replaceBeforeRename = '' + } + return actual.rename(...args) + } + } +}) + vi.mock('electron', () => ({ app: { isPackaged: true } })) +vi.mock('./appimage-registration-lock', () => ({ + withAppImageRegistrationLock: async ( + cacheRootPath: string, + operation: () => Promise + ): Promise => { + registrationLock.entered?.(cacheRootPath) + const pause = registrationLock.pause + registrationLock.pause = null + await pause + const result = await operation() + registrationLock.completed?.(cacheRootPath) + return result + } +})) + import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' const created: string[] = [] @@ -22,10 +72,27 @@ async function makeFixture(): Promise<{ homePath: string; resourcesPath: string } afterEach(async () => { + vi.unstubAllEnvs() + filePublicationFailures.link = 0 + filePublicationFailures.replaceBeforeRename = '' + registrationLock.completed = null + registrationLock.entered = null + registrationLock.pause = null await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) describe('installLinuxBareOrcaDispatcher', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const { homePath, resourcesPath } = await makeFixture() + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', resourcesPath) + + const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath }) + + expect(result.state).toBe('installed') + expect(result.target).toBe(join(resourcesPath, 'bin', 'orca-ide')) + }) + it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => { const { homePath, resourcesPath } = await makeFixture() @@ -67,6 +134,39 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(second.state).toBe('installed') }) + it('publishes when the home filesystem does not support hard links', async () => { + const { homePath, resourcesPath } = await makeFixture() + filePublicationFailures.link = 1 + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('installed') + await expect(readFile(result.dispatcherPath, 'utf8')).resolves.toContain( + '# orca-serve-bare-orca-dispatcher' + ) + }) + + it('restores a displaced foreign dispatcher when hard links are unsupported', async () => { + const { homePath, resourcesPath } = await makeFixture() + await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath: null }) + const foreignContent = '#!/bin/sh\necho foreign\n' + filePublicationFailures.replaceBeforeRename = foreignContent + filePublicationFailures.link = 2 + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('skipped-foreign') + await expect(readFile(result.dispatcherPath, 'utf8')).resolves.toBe(foreignContent) + }) + it('quotes a resources path containing spaces so the exec line cannot be split', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-')) created.push(root) @@ -84,36 +184,154 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) }) - it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { - const { homePath, resourcesPath } = await makeFixture() - const appImagePath = join(homePath, 'Applications', 'Orca.AppImage') + // Why: this dispatcher must survive a restart, and an AppImage's resourcesPath + // is a mount that dies with the app. Point it at the extracted payload, which + // also keeps it clear of AppRun's `--no-sandbox` injection (#11609). + it.skipIf(process.platform === 'win32')( + 'execs the extracted payload (not the ephemeral mount) when running from an AppImage', + async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(homePath, 'cache') - const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath }) + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_appImagePath, cwd) => { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '', { encoding: 'utf8', mode: 0o755 }) + } + }) - expect(result.state).toBe('installed') - expect(result.target).toBe(appImagePath) - const content = await readFile(result.dispatcherPath, 'utf8') - // The AppImage wrapper references the stable outer path, never resourcesPath. - expect(content).toContain(appImagePath) - expect(content).not.toContain(resourcesPath) - }) + expect(result.state).toBe('installed') + expect(relative(cacheRootPath, result.target as string).split(sep)).toEqual([ + 'launcher', + 'orca-ide' + ]) + const content = await readFile(result.dispatcherPath, 'utf8') + expect(content).toContain(result.target as string) + expect(content).not.toContain(resourcesPath) + expect(content).not.toContain(appImagePath) + } + ) it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => { const { homePath, resourcesPath } = await makeFixture() const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + const appImagePath = join(homePath, 'Orca.AppImage') await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', 'utf8') + const extract = vi.fn() const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, - appImagePath: null + appImagePath, + appImageExtractRunner: extract }) expect(result.state).toBe('skipped-foreign') + expect(result.target).toBeNull() + expect(extract).not.toHaveBeenCalled() expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n') }) + it('preserves a foreign dispatcher created while AppImage extraction is in flight', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + const cacheRootPath = join(homePath, 'cache') + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + let reportStarted!: () => void + let releaseExtraction!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + const released = new Promise((resolve) => { + releaseExtraction = resolve + }) + + const installation = installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + await writePayload(cwd) + reportStarted() + await released + } + }) + await started + await mkdir(dirname(dispatcherPath), { recursive: true }) + await writeFile(dispatcherPath, '#!/bin/sh\necho foreign\n', { mode: 0o755 }) + releaseExtraction() + + await expect(installation).resolves.toMatchObject({ + state: 'skipped-foreign', + target: null + }) + await expect(readFile(dispatcherPath, 'utf8')).resolves.toBe('#!/bin/sh\necho foreign\n') + }) + + it('prunes old owner generations without touching a sibling namespace', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + const cacheRootPath = join(homePath, 'cache', 'unused', '..') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const events: string[] = [] + const options = { + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path: string, cwd: string) => { + events.push('extract') + await writePayload(cwd) + } + } + + await installLinuxBareOrcaDispatcher(options) + const previous = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const sibling = join(resolve(cacheRootPath), 'f'.repeat(24), 'e'.repeat(24)) + await mkdir(sibling, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n# next\n', { mode: 0o755 }) + const lockEntered = Promise.withResolvers() + const releaseLock = Promise.withResolvers() + events.length = 0 + registrationLock.pause = releaseLock.promise + registrationLock.entered = (rootPath) => { + events.push('lock-entered') + lockEntered.resolve(rootPath) + } + registrationLock.completed = () => { + events.push( + existsSync(previous.rootPath) ? 'lock-left-before-prune' : 'lock-left-after-prune' + ) + } + + const installation = installLinuxBareOrcaDispatcher(options) + await expect(lockEntered.promise).resolves.toBe(resolve(cacheRootPath)) + expect(events).toEqual(['lock-entered']) + expect(existsSync(previous.rootPath)).toBe(true) + releaseLock.resolve() + await installation + const current = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + + expect(events).toEqual(['lock-entered', 'extract', 'lock-left-after-prune']) + expect(existsSync(previous.rootPath)).toBe(false) + expect(existsSync(current.rootPath)).toBe(true) + expect(existsSync(sibling)).toBe(true) + }) + it('skips when the bundled orca-ide launcher is missing from the build', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-')) created.push(root) @@ -128,3 +346,9 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(result.target).toBeNull() }) }) + +async function writePayload(cwd: string): Promise { + const launcherDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDirectory, { recursive: true }) + await writeFile(join(launcherDirectory, 'orca-ide'), '#!/usr/bin/env bash\n', { mode: 0o755 }) +} diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 3dc8df2906c..6c4b273fcd8 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -1,9 +1,20 @@ -import { existsSync } from 'node:fs' -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { constants, existsSync } from 'node:fs' +import { copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper' -import { getBundledLauncherPath } from './cli-installer' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + ensureAppImageExtractedRoot, + resolveAppImageCacheRootPath +} from './appimage-extracted-root' +import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { quoteShell } from './cli-install-path-format' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. @@ -14,8 +25,12 @@ export type LinuxBareOrcaDispatcherOptions = { resourcesPath: string /** Test seam — defaults to the real home directory. */ homePath?: string - /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + /** Trusted caller override; production requires the complete AppImage runtime identity. */ appImagePath?: string | null + /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ + appImageCacheRootPath?: string + /** Test seam — defaults to running the AppImage's own `--appimage-extract`. */ + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } export type LinuxBareOrcaDispatcherState = @@ -26,7 +41,7 @@ export type LinuxBareOrcaDispatcherState = export type LinuxBareOrcaDispatcherResult = { state: LinuxBareOrcaDispatcherState dispatcherPath: string - /** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */ + /** The bundled `orca-ide` launcher the dispatcher execs. */ target: string | null } @@ -41,73 +56,176 @@ export async function installLinuxBareOrcaDispatcher( options: LinuxBareOrcaDispatcherOptions ): Promise { const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca') - const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null + if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { + return { state: 'skipped-foreign', dispatcherPath, target: null } + } - const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath) - if (!resolved) { + const launcher = await resolveStableLauncherPath(options) + if (!launcher) { return { state: 'skipped-launcher-missing', dispatcherPath, target: null } } - // Why: only (re)write a dispatcher we previously created; leave a user's own - // `orca` untouched rather than silently clobbering it on every serve start. - if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { - return { state: 'skipped-foreign', dispatcherPath, target: resolved.target } - } - - await mkdir(dirname(dispatcherPath), { recursive: true }) - await writeFile(dispatcherPath, resolved.script, 'utf8') - await chmod(dispatcherPath, 0o755) - return { state: 'installed', dispatcherPath, target: resolved.target } + const installed = await publishDispatcher( + dispatcherPath, + insertDispatcherMarker(buildBareOrcaCliScript(launcher)) + ) + return installed + ? { state: 'installed', dispatcherPath, target: launcher } + : { state: 'skipped-foreign', dispatcherPath, target: null } } -/** Bare-`orca` script that execs the Orca CLI: the stable AppImage when running - * from one, otherwise the bundled `orca-ide` launcher. Shared by the serve - * dispatcher and the managed-terminal PATH shim. */ -export function buildBareOrcaCliScript( - resourcesPath: string, - appImagePath: string | null -): { script: string; target: string } | null { - if (appImagePath) { - // Why: an AppImage mounts resources under an ephemeral FUSE path per launch, - // so the script must exec the stable outer AppImage — reuse the same - // wrapper CliInstaller installs for the AppImage command. - return { script: buildAppImageCliWrapper(appImagePath), target: appImagePath } - } +/** Bare-`orca` script that execs the one Linux CLI launcher. */ +export function buildBareOrcaCliScript(launcherPath: string): string { + return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` +} - const launcher = getBundledLauncherPath('linux', resourcesPath) +/** + * The launcher path this dispatcher can still reach on a later boot. Under an + * AppImage `process.resourcesPath` is an ephemeral FUSE mount that dies with the + * app, so extract the payload once and point at that stable copy instead. + */ +async function resolveStableLauncherPath( + options: LinuxBareOrcaDispatcherOptions +): Promise { + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath: options.resourcesPath }) + if (!hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeIdentity) { + return null + } + const appImagePath = hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeIdentity?.appImagePath ?? null) + if (appImagePath) { + const extractionOptions = { + appImagePath, + cacheRootPath: options.appImageCacheRootPath, + runExtract: options.appImageExtractRunner + } + return withAppImageRegistrationLock( + resolveAppImageCacheRootPath(extractionOptions), + async () => { + const extractedRoot = await ensureAppImageExtractedRoot(extractionOptions) + if (extractedRoot) { + await pruneAppImageExtractedRoots(extractedRoot.rootPath) + } + return extractedRoot?.stableLauncherPath ?? null + } + ) + } + const launcher = getBundledLauncherPath('linux', options.resourcesPath) // Why: getBundledLauncherPath only joins the path; guard existence so we never // write a script pointing at a missing launcher (which would fail at exec // time with a confusing error instead of the command-not-found we fix). - if (!launcher || !existsSync(launcher)) { - return null - } - return { - script: `#!/usr/bin/env bash\nexec ${quoteShell(launcher)} "$@"\n`, - target: launcher - } + return launcher && existsSync(launcher) ? launcher : null } -function resolveDispatcherScript( - resourcesPath: string, - appImagePath: string | null -): { script: string; target: string } | null { - const resolved = buildBareOrcaCliScript(resourcesPath, appImagePath) - return resolved && { script: withMarker(resolved.script), target: resolved.target } -} - -function withMarker(script: string): string { - const firstNewline = script.indexOf('\n') - if (firstNewline === -1) { - return `${script}\n${DISPATCHER_MARKER}\n` - } - // Keep the shebang on line 1; insert the marker immediately after it. - return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}` +function insertDispatcherMarker(script: string): string { + return script.replace('\n', `\n${DISPATCHER_MARKER}\n`) } async function isOwnedDispatcher(dispatcherPath: string): Promise { try { - return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER) + return ( + (await lstat(dispatcherPath)).isFile() && + (await readFile(dispatcherPath, 'utf8')).split('\n')[1] === DISPATCHER_MARKER + ) } catch { return false } } + +async function publishDispatcher(dispatcherPath: string, content: string): Promise { + const directoryPath = dirname(dispatcherPath) + const temporaryPath = join(directoryPath, `.orca-dispatcher-${process.pid}-${randomUUID()}`) + await mkdir(directoryPath, { recursive: true }) + await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o755 }) + try { + if (await publishIfVacant(temporaryPath, dispatcherPath)) { + return true + } + + const displacedPath = join( + directoryPath, + `.orca-preserved-dispatcher-${process.pid}-${randomUUID()}` + ) + try { + await rename(dispatcherPath, displacedPath) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + return await publishIfVacant(temporaryPath, dispatcherPath) + } + + if (!(await isOwnedDispatcher(displacedPath))) { + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + return false + } + + try { + if ( + (await publishIfVacant(temporaryPath, dispatcherPath)) || + (await isExactExecutableDispatcher(dispatcherPath, content)) + ) { + await unlink(displacedPath) + return true + } + // A concurrently published foreign command owns the public path now. + await unlink(displacedPath) + return false + } catch (error) { + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + throw error + } + } finally { + await unlink(temporaryPath).catch(() => {}) + } +} + +async function publishIfVacant(sourcePath: string, destinationPath: string): Promise { + try { + await link(sourcePath, destinationPath) + return true + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return false + } + } + try { + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return true + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return false + } + throw error + } +} + +async function restoreDisplacedDispatcher( + displacedPath: string, + dispatcherPath: string +): Promise { + if (await publishIfVacant(displacedPath, dispatcherPath)) { + await unlink(displacedPath) + } +} + +async function isExactExecutableDispatcher( + dispatcherPath: string, + content: string +): Promise { + try { + const [actual, metadata] = await Promise.all([ + readFile(dispatcherPath, 'utf8'), + lstat(dispatcherPath) + ]) + return metadata.isFile() && (metadata.mode & 0o111) !== 0 && actual === content + } catch { + return false + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code +} diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 81af17ba779..905d4807368 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -1,16 +1,19 @@ -import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' vi.mock('electron', () => ({ app: { isPackaged: true } })) +import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' const created: string[] = [] +const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-')) @@ -23,10 +26,24 @@ async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: str } afterEach(async () => { + vi.unstubAllEnvs() await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) describe('ensureLinuxTerminalOrcaCliShimDir', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', resourcesPath) + + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath, resourcesPath }) + + expect(shimDir).toBe(join(userDataPath, 'linux-orca-cli-shim')) + expect(readFileSync(join(shimDir!, 'orca'), 'utf8')).toContain( + `exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"` + ) + }) + it('writes an executable bare-orca shim that execs the bundled orca-ide launcher', async () => { const { userDataPath, resourcesPath } = await makeFixture() @@ -44,7 +61,7 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(mode & 0o111).not.toBe(0) }) - it('memoizes per userDataPath and re-asserts the exec bit for a stale shim', async () => { + it('reuses the shim path and re-asserts its exec bit', async () => { const { userDataPath, resourcesPath } = await makeFixture() const options = { userDataPath, resourcesPath, appImagePath: null } @@ -53,10 +70,9 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const shimPath = join(first!, 'orca') chmodSync(shimPath, 0o644) - // A distinct userData path is not memoized, so ensure runs again and heals - // the exec bit lost above only when it actually processes that path. const second = ensureLinuxTerminalOrcaCliShimDir(options) expect(second).toBe(first) + expect(statSync(shimPath).mode & 0o111).not.toBe(0) const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-2-')) created.push(root) @@ -76,20 +92,131 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(statSync(healedPath).mode & 0o111).not.toBe(0) }) - it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Applications', 'Orca.AppImage') + it.skipIf(!canFenceAppImageRuntime)( + 'routes first-use AppImage terminals through a fenced current mount without a live endpoint', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', 'utf8') + chmodSync(liveLauncherPath, 0o755) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + }) - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ - userDataPath, - resourcesPath, - appImagePath - }) + const shimPath = join(shimDir!, 'orca') + const content = readFileSync(shimPath, 'utf8') + expect(content).toContain(liveLauncherPath) + expect(content).toContain('runtime_pid=') + expect(content).toContain('/proc/$runtime_pid/stat') + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'live' }) + } + ) - const content = readFileSync(join(shimDir!, 'orca'), 'utf8') - expect(content).toContain(appImagePath) - expect(content).not.toContain(resourcesPath) - }) + it.skipIf(!canFenceAppImageRuntime)( + 'refreshes restored terminals to the current AppImage mount', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const firstLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(firstLauncher, '#!/usr/bin/env bash\nprintf first', 'utf8') + chmodSync(firstLauncher, 0o755) + const options = { + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + } + const shimDir = ensureLinuxTerminalOrcaCliShimDir(options) + const shimPath = join(shimDir!, 'orca') + const originalShim = readFileSync(shimPath, 'utf8') + + const nextResourcesPath = join(userDataPath, 'next-mount', 'resources') + const nextLauncher = join(nextResourcesPath, 'bin', 'orca-ide') + await mkdir(join(nextResourcesPath, 'bin'), { recursive: true }) + await writeFile(nextLauncher, '#!/usr/bin/env bash\nprintf next', { mode: 0o755 }) + await rm(firstLauncher) + expect( + ensureLinuxTerminalOrcaCliShimDir({ ...options, resourcesPath: nextResourcesPath }) + ).toBe(shimDir) + + const refreshedShim = readFileSync(shimPath, 'utf8') + expect(refreshedShim).not.toBe(originalShim) + expect(refreshedShim).toContain(nextLauncher) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'next' }) + } + ) + + it.skipIf(!canFenceAppImageRuntime)( + 'rejects a stale shim when its mount path is removed and reused', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + const cacheRootPath = join(userDataPath, 'cache') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + }) + const shimPath = join(shimDir!, 'orca') + await rm(liveLauncher) + await writeFile(liveLauncher, '#!/usr/bin/env bash\nprintf replaced-by-another-mount', { + mode: 0o755 + }) + + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 1, stdout: '' }) + } + ) + + it.skipIf(!canFenceAppImageRuntime)( + 'rejects a shim after its owning AppImage process generation changes', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: join(userDataPath, 'cache') + }) + const shimPath = join(shimDir!, 'orca') + const staleContent = readFileSync(shimPath, 'utf8').replace( + /runtime_start_time='[^']*'/, + "runtime_start_time='stale-process'" + ) + writeFileSync(shimPath, staleContent, { mode: 0o755 }) + + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 1, stdout: '' }) + } + ) it('returns null (and does not memoize) when the bundled launcher is missing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-missing-')) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 56f38df15ce..7697bac01ac 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -1,20 +1,39 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, + type Stats +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + getAppImageCacheRootPath, + isAppImageInstalledLauncherCurrent +} from './appimage-extracted-root' +import { + ensureAppImageStableLauncher, + removeAppImageLegacyLiveEndpoint +} from './appimage-stable-launcher' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' +import { quoteShell } from './cli-install-path-format' const SHIM_DIR_NAME = 'linux-orca-cli-shim' -// Why: rewriting the shim on every PTY spawn is wasted fs work; the target only -// changes with the install itself, so one successful write per process is enough. -// Failures are NOT cached so a transient fs error retries on the next spawn. -const ensuredShimDirs = new Map() - export type LinuxTerminalOrcaCliShimOptions = { userDataPath: string /** Test seam — defaults to the packaged resources root. */ resourcesPath?: string | null - /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + /** Trusted caller override; production requires the complete AppImage runtime identity. */ appImagePath?: string | null + /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ + appImageCacheRootPath?: string } // Why: on Linux the CLI installs as `orca-ide` so it never shadows the GNOME @@ -27,37 +46,185 @@ export type LinuxTerminalOrcaCliShimOptions = { export function ensureLinuxTerminalOrcaCliShimDir( options: LinuxTerminalOrcaCliShimOptions ): string | null { - const cached = ensuredShimDirs.get(options.userDataPath) - if (cached !== undefined) { - return cached + const resourcesPath = + options.resourcesPath === undefined ? process.resourcesPath : options.resourcesPath + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath }) + if (!hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeIdentity) { + return null + } + const appImagePath = hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeIdentity?.appImagePath ?? null) + if (appImagePath) { + return ensureAppImageShim(options, resourcesPath, appImagePath) } - const resourcesPath = options.resourcesPath ?? process.resourcesPath if (!resourcesPath) { return null } - const resolved = buildBareOrcaCliScript( - resourcesPath, - options.appImagePath ?? process.env.APPIMAGE ?? null - ) - if (!resolved) { + const launcherPath = getBundledLauncherPath('linux', resourcesPath) + return launcherPath && existsSync(launcherPath) + ? ensureShimForLauncher(options.userDataPath, launcherPath) + : null +} + +function ensureAppImageShim( + options: LinuxTerminalOrcaCliShimOptions, + resourcesPath: string | null, + appImagePath: string +): string | null { + if (!resourcesPath) { + return null + } + const cacheRootPath = options.appImageCacheRootPath ?? getAppImageCacheRootPath() + removeAppImageLegacyLiveEndpoint(cacheRootPath) + + const liveLauncherPath = getBundledLauncherPath('linux', resourcesPath) + if (!liveLauncherPath || !existsSync(liveLauncherPath)) { return null } - const shimDir = join(options.userDataPath, SHIM_DIR_NAME) + const stableLauncherPath = ensureAppImageStableLauncher(cacheRootPath) + if ( + stableLauncherPath && + isAppImageInstalledLauncherCurrent({ + appImagePath, + cacheRootPath + }) + ) { + return ensureShimForLauncher(options.userDataPath, stableLauncherPath) + } + + const fence = captureAppImageRuntimeFence(liveLauncherPath) + return fence + ? ensureShimForScript( + options.userDataPath, + buildAppImageLiveLauncherScript(fence.launcherPath, fence) + ) + : null +} + +type AppImageRuntimeFence = { + pid: number + startTime: string + runtimeRoot: string + runtimeIdentity: string + launcherIdentity: string + launcherPath: string +} + +function captureAppImageRuntimeFence(launcherPath: string): AppImageRuntimeFence | null { + if (process.platform !== 'linux') { + return null + } + const resolvedLauncherPath = resolve(launcherPath) + const runtimeRoot = dirname(dirname(dirname(resolvedLauncherPath))) + const runtimeIdentity = readFileIdentity(runtimeRoot) + const launcherIdentity = readFileIdentity(resolvedLauncherPath) + const startTime = readLinuxProcessStartTime(process.pid) + return runtimeIdentity && launcherIdentity && startTime + ? { + pid: process.pid, + startTime, + runtimeRoot, + runtimeIdentity, + launcherIdentity, + launcherPath: resolvedLauncherPath + } + : null +} + +function readFileIdentity(path: string): string | null { + try { + const stats = statSync(path) + return formatFileIdentity(stats) + } catch { + return null + } +} + +function formatFileIdentity(stats: Stats): string { + return [ + stats.dev, + stats.ino, + stats.size, + Math.floor(stats.mtimeMs / 1000), + Math.floor(stats.ctimeMs / 1000) + ].join(':') +} + +function readLinuxProcessStartTime(pid: number): string | null { + try { + const content = readFileSync(join('/proc', String(pid), 'stat'), 'utf8') + const commandEnd = content.lastIndexOf(') ') + if (commandEnd === -1) { + return null + } + const fields = content + .slice(commandEnd + 2) + .trim() + .split(/\s+/) + return fields[19] ?? null + } catch { + return null + } +} + +function buildAppImageLiveLauncherScript( + launcherPath: string, + fence: AppImageRuntimeFence +): string { + const runtimePid = quoteShell(String(fence.pid)) + const runtimeStartTime = quoteShell(fence.startTime) + const runtimeRoot = quoteShell(fence.runtimeRoot) + const expectedRuntimeIdentity = quoteShell(fence.runtimeIdentity) + const expectedLauncherIdentity = quoteShell(fence.launcherIdentity) + const quotedLauncherPath = quoteShell(launcherPath) + return `#!/usr/bin/env bash +runtime_pid=${runtimePid} +runtime_start_time=${runtimeStartTime} +runtime_root=${runtimeRoot} +launcher=${quotedLauncherPath} +expected_runtime_identity=${expectedRuntimeIdentity} +expected_launcher_identity=${expectedLauncherIdentity} +fail() { + printf 'Orca CLI is unavailable; reopen Orca or register the CLI again.\\n' >&2 + exit 1 +} +proc_stat_path="/proc/$runtime_pid/stat" +[[ -r "$proc_stat_path" ]] || fail +proc_stat="$(<"$proc_stat_path")" || fail +proc_fields=() +read -r -a proc_fields <<< "\${proc_stat##*) }" || fail +[[ "\${proc_fields[19]:-}" == "$runtime_start_time" ]] || fail +runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" || fail +[[ "$runtime_identity" == "$expected_runtime_identity" ]] || fail +launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail +[[ "$launcher_identity" == "$expected_launcher_identity" ]] || fail +[[ -f "$launcher" && -x "$launcher" ]] || fail +exec "$launcher" "$@" +` +} + +function ensureShimForLauncher(userDataPath: string, launcherPath: string): string | null { + const script = buildBareOrcaCliScript(launcherPath) + + return ensureShimForScript(userDataPath, script) +} + +function ensureShimForScript(userDataPath: string, script: string): string | null { + const shimDir = join(userDataPath, SHIM_DIR_NAME) const shimPath = join(shimDir, 'orca') try { - if (readShim(shimPath) !== resolved.script) { + if (readShim(shimPath) !== script) { mkdirSync(shimDir, { recursive: true }) - writeFileSync(shimPath, resolved.script, 'utf8') + writeFileSync(shimPath, script, 'utf8') } - // Why: always re-assert the exec bit — a shim written by an older run (or - // restored from backup) with mode stripped would fail every agent CLI call. chmodSync(shimPath, 0o755) } catch { return null } - ensuredShimDirs.set(options.userDataPath, shimDir) return shimDir } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 9128f945ef9..1fb71e3e00f 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os' import { dirname, join, sep } from 'node:path' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' const require = createRequire(import.meta.url) const execFileAsync = promisify(execFile) @@ -246,55 +245,47 @@ printf 'arg=%s\\n' "$@" } ) - itRunsUnixShell('runs the AppImage CLI wrapper through APPDIR at runtime', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-')) + // Why: registration on every Linux install method now points at this one + // launcher, so its env sanitation and argv passthrough are the contract the + // AppImage, deb, and extracted-tree commands all depend on. + itRunsUnixShell('sanitizes node env and forwards argv verbatim', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-env-')) try { - const appDir = join(root, 'Orca.AppDir') - const cliDir = join(appDir, 'resources', 'app.asar.unpacked', 'out', 'cli') + 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 cliPath = join(cliDir, 'index.js') - const appImagePath = join(root, "Orca's AppImage.AppImage") - const commandPath = join(root, 'orca-ide') + + await mkdir(launcherDir, { recursive: true }) await mkdir(cliDir, { recursive: true }) + await copyFile(linuxLauncherAsset, launcherPath) + await writeFile(cliPath, '', 'utf8') await writeFile( - cliPath, - `exports.main = (argv) => { - console.log(JSON.stringify({ - argv, - appDir: process.env.APPDIR, - runAsNode: process.env.ELECTRON_RUN_AS_NODE, - nodeOptions: process.env.NODE_OPTIONS ?? null, - orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, - nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, - orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null - })) -} -`, - 'utf8' - ) - await writeFile( - appImagePath, + join(appDir, 'orca-ide'), `#!/usr/bin/env bash -export APPDIR="$FAKE_APPDIR" -exec node "$@" +node -e 'console.log(JSON.stringify({ + argv: process.argv.slice(1), + runAsNode: process.env.ELECTRON_RUN_AS_NODE, + nodeOptions: process.env.NODE_OPTIONS ?? null, + orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, + nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, + orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null +}))' -- "$@" `, { encoding: 'utf8', mode: 0o755 } ) - await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { - encoding: 'utf8', - mode: 0o755 - }) - const result = await execFileAsync(commandPath, ['--help', 'two words'], { + const result = await execFileAsync(launcherPath, ['--help', 'two words'], { env: { ...process.env, - FAKE_APPDIR: appDir, NODE_OPTIONS: '--trace-warnings', NODE_REPL_EXTERNAL_MODULE: 'external-loader' } }) const payload = JSON.parse(result.stdout) as { argv: string[] - appDir: string runAsNode: string nodeOptions: string | null orcaNodeOptions: string | null @@ -302,9 +293,10 @@ exec node "$@" orcaNodeReplExternalModule: string | null } - expect(payload.argv).toEqual(['--help', 'two words']) - expect(payload.appDir).toBe(appDir) + expect(payload.argv).toEqual([cliPath, '--help', 'two words']) expect(payload.runAsNode).toBe('1') + // Why: Electron's node bootstrap must not inherit these, but the CLI + // still needs to see what the user set. expect(payload.nodeOptions).toBeNull() expect(payload.orcaNodeOptions).toBe('--trace-warnings') expect(payload.nodeReplExternalModule).toBeNull() @@ -313,6 +305,63 @@ exec node "$@" 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-installer.ts b/src/main/cli/wsl-cli-installer.ts index a3c102e1dfc..484ed4f9bc3 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -207,7 +207,9 @@ export class WslCliInstaller { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } await this.run( 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/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts new file mode 100644 index 00000000000..5a5cd7406c4 --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { validate } from '../telemetry/validator' + +const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted( + () => ({ + trackMock: vi.fn(), + accessSyncMock: vi.fn(), + existsSyncMock: vi.fn(() => true), + readFileSyncMock: vi.fn(), + getVersionMock: vi.fn(() => '1.4.191') + }) +) +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + accessSync: accessSyncMock, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock +})) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal>()), + homedir: () => '/Users/alice' +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: getVersionMock }) +})) + +import { + classifyDaemonAdoptionOrigin, + trackDaemonAdopted, + trackDaemonPtyCwdDeniedIfDiverged +} from './daemon-adoption-telemetry-event' + +const stalePidRecord: ParsedDaemonPid = { + pid: 1530, + startedAtMs: 1, + entryPath: '/x/daemon-entry.js', + appVersion: '1.4.187', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: + '/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca' +} +const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const +const PID_PATH = '/fake/daemon.pid' + +beforeEach(() => { + trackMock.mockReset() + accessSyncMock.mockReset() + existsSyncMock.mockReset().mockReturnValue(true) + readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord)) + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('classifyDaemonAdoptionOrigin', () => { + it('compares the recorded app version and classifies the spawner path', () => { + expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin) + expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({ + app_version_match: 'same', + spawner_path_class: 'updater-cache' + }) + expect(classifyDaemonAdoptionOrigin(null)).toEqual({ + app_version_match: 'unknown', + spawner_path_class: 'unknown' + }) + }) +}) + +describe('trackDaemonAdopted', () => { + it('emits a validator-accepted payload', () => { + trackDaemonAdopted(stalePidRecord, 'intact', 7) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_adopted') + expect(props).toEqual({ + ...origin, + tcc_attribution: 'intact', + live_session_count_bucket: '6+' + }) + expect(validate('daemon_adopted', props).ok).toBe(true) + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow() + }) +}) + +describe('trackDaemonPtyCwdDeniedIfDiverged', () => { + it('emits only when the daemon was denied and the app can read the same cwd', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number)) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_pty_cwd_denied') + expect(props).toEqual({ cwd_class: 'documents', ...origin }) + expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true) + }) + + // False positives would drown the signal this event exists to measure, so every + // non-divergent shape must stay silent. + it('stays silent when the daemon could read the cwd or did not report', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent when the app cannot read the cwd either (no divergence)', () => { + accessSyncMock.mockImplementation(() => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + }) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => { + readFileSyncMock.mockReturnValue( + JSON.stringify({ + ...stalePidRecord, + appVersion: '1.4.191', + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8') + expect(trackMock.mock.calls[0][1]).toEqual({ + cwd_class: 'documents', + app_version_match: 'same', + spawner_path_class: 'applications' + }) + }) + + it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => { + getVersionMock.mockImplementationOnce(() => { + throw new Error('AppEnvironment not initialized') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent off macOS', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts new file mode 100644 index 00000000000..47f554bf9bb --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -0,0 +1,81 @@ +// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the +// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal. + +import { accessSync, constants as fsConstants, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { getAppEnvironment } from '../../shared/app-environment' +import { + classifyDaemonPtyCwd, + classifyDaemonSpawnerPath, + type DaemonAdoptedAppVersionMatch, + type DaemonSpawnerPathClass +} from '../../shared/daemon-adoption-telemetry' +import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry' +import type { EventProps } from '../../shared/telemetry-events' +import { track } from '../telemetry/client' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' + +export type DaemonAdoptionOrigin = Pick< + EventProps<'daemon_pty_cwd_denied'>, + 'app_version_match' | 'spawner_path_class' +> + +/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ +export function classifyDaemonAdoptionOrigin( + pidRecord: ParsedDaemonPid | null +): DaemonAdoptionOrigin { + const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion + ? 'unknown' + : pidRecord.appVersion === getAppEnvironment().getVersion() + ? 'same' + : 'different' + const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath( + pidRecord?.spawnerExecPath ?? null, + existsSync + ) + return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass } +} + +// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters). +export function trackDaemonAdopted( + pidRecord: ParsedDaemonPid | null, + tccAttribution: MacDaemonTccAttributionHealth, + liveSessionCount: number | null +): void { + try { + track('daemon_adopted', { + ...classifyDaemonAdoptionOrigin(pidRecord), + tcc_attribution: tccAttribution, + live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount) + }) + } catch { + // Telemetry is best-effort; a dropped event must not fail daemon adoption. + } +} + +/** + * Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can + * read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape. + */ +export function trackDaemonPtyCwdDeniedIfDiverged( + cwd: string | undefined, + cwdReadableByDaemon: boolean | undefined, + pidPath: string | null +): void { + try { + if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) { + return + } + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + // Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a + // long-lived adapter, and the denial must be attributed to the daemon that just spawned. + track('daemon_pty_cwd_denied', { + cwd_class: classifyDaemonPtyCwd(cwd, homedir()), + ...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath)) + }) + } catch { + // Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller. + } +} diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts index b495ee07425..75d9d9edfd6 100644 --- a/src/main/daemon/daemon-client-rpc-request.test.ts +++ b/src/main/daemon/daemon-client-rpc-request.test.ts @@ -21,9 +21,66 @@ function addSiblingRequest(pendingRequests: DaemonPendingRequests, reject: () => describe('requestDaemonRpc', () => { afterEach(() => { + vi.restoreAllMocks() vi.useRealTimers() }) + it('keeps the daemon attach guard behind the client timeout window', async () => { + const pendingRequests = new DaemonPendingRequests() + const abort = new AbortController() + const write = vi.fn() + const request = requestDaemonRpc({ + socket: { write } as unknown as Socket, + pendingRequests, + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'guarded-spawn' }, + timeoutMs: 30_000, + signal: abort.signal, + unmatchedCancelGraceMs: 5_000, + onCreateCancellationFailure: vi.fn(), + settleCreateCancellation: vi.fn(async () => ({ canceled: true })) + }) + + expect(JSON.parse(String(write.mock.calls[0]?.[0]))).toMatchObject({ + payload: { sessionId: 'guarded-spawn', cancelAfterMs: 35_000 } + }) + abort.abort() + await expect(request).rejects.toThrow('client_disconnected') + }) + + it('classifies a cancellation delivered after an overdue timeout as a timeout', async () => { + vi.useFakeTimers() + const monotonicNow = vi.spyOn(performance, 'now').mockReturnValue(0) + const pendingRequests = new DaemonPendingRequests() + const settleCreateCancellation = vi.fn(() => new Promise<{ canceled: boolean }>(() => {})) + const request = requestDaemonRpc({ + socket: { write: vi.fn() } as unknown as Socket, + pendingRequests, + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'deadline-spawn' }, + timeoutMs: 10, + unmatchedCancelGraceMs: 5, + onCreateCancellationFailure: vi.fn(), + settleCreateCancellation + }) + const rejected = expect(request).rejects.toMatchObject({ + name: 'DaemonRequestTimeoutError', + message: 'Request createOrAttach timed out after 10ms' + }) + + monotonicNow.mockReturnValue(16) + pendingRequests.settle({ + id: 'req-1', + ok: false, + error: 'Attach canceled for session deadline-spawn' + }) + + await rejected + expect(settleCreateCancellation).not.toHaveBeenCalled() + }) + it('keeps a completed spawn result when cancellation arrives too late', async () => { const pendingRequests = new DaemonPendingRequests() const abort = new AbortController() diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts index 33936a9286a..fb72538eaa0 100644 --- a/src/main/daemon/daemon-client-rpc-request.ts +++ b/src/main/daemon/daemon-client-rpc-request.ts @@ -54,20 +54,27 @@ function wedgedDaemonError(requestError: Error, cancelError: unknown): Error | n } export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { + // A stalled event loop can deliver a daemon cancellation before its overdue timer runs. const { payload, type } = opts + const createTimeoutError = (): DaemonRequestTimeoutError => + new DaemonRequestTimeoutError(`Request ${type} timed out after ${opts.timeoutMs}ms`) const createSessionId = type === 'createOrAttach' && payload !== null && typeof payload === 'object' ? Reflect.get(payload, 'sessionId') : null const requestPayload = type === 'createOrAttach' && payload !== null && typeof payload === 'object' - ? { ...payload, cancelAfterMs: Math.max(1, opts.timeoutMs - 100) } + ? { + ...payload, + cancelAfterMs: Math.max(1, opts.timeoutMs + opts.unmatchedCancelGraceMs) + } : payload const encoded = encodeNdjson({ id: opts.id, type, ...(requestPayload !== undefined ? { payload: requestPayload } : {}) }) + const clientDeadlineMs = performance.now() + opts.timeoutMs return new Promise((resolve, reject) => { let sent = false @@ -146,9 +153,7 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { }) } const timer = setTimeout(() => { - const error = new DaemonRequestTimeoutError( - `Request ${type} timed out after ${opts.timeoutMs}ms` - ) + const error = createTimeoutError() if (typeof createSessionId === 'string') { cancelCreate(error) } else { @@ -172,8 +177,11 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { removeAbortListener() clearTimers() reject( - cancellationError !== null && isTerminalAttachCanceledMessage(error.message) - ? cancellationError + isTerminalAttachCanceledMessage(error.message) + ? (cancellationError ?? + (type === 'createOrAttach' && performance.now() >= clientDeadlineMs + ? createTimeoutError() + : error)) : error ) }, diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index d6668342487..92a7e451a10 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = { wslDistro?: string | null agentSessionEnsure?: AgentSessionClaimedSpawnResult incarnationId?: PtyIncarnationId + /** + * Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own + * verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same + * path proves nothing about the daemon's (#17696). Omitted by daemons predating this field. + */ + cwdReadableByDaemon?: boolean } export function getDaemonSessionResultMetadata(session: { diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index a7e8f2b6db2..6d94bea06e4 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -34,7 +34,7 @@ export type RelocatedDaemonHost = { const HOST_SUBDIR = 'daemon-host' const MARKER_NAME = '.materialized.json' -// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync. +// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' // Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it. diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index d920a13866e..d7217d4df63 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state // Why: both fakes are annotated with constructor types so the exported factories widen to @@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { if (result.mode) { this.handle.mode = result.mode } + if (result.adopted) { + this.handle.adopted = true + } return { socketPath: result.socketPath, tokenPath: result.tokenPath @@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { trackDaemonReplaced: trackDaemonReplacedMock, trackDaemonRetired: trackDaemonRetiredMock }), + daemonAdoptionTelemetryEvent: () => ({ + trackDaemonAdopted: trackDaemonAdoptedMock + }), daemonSpawner: () => ({ DaemonSpawner: MockDaemonSpawner, getDaemonSocketPath: (_dir: string, version?: number) => diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index e1cc0416337..39f3625c063 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state vi.resetModules() @@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { rebindLocalProviderListenersMock.mockClear() trackDaemonReplacedMock.mockClear() trackDaemonRetiredMock.mockClear() + trackDaemonAdoptedMock.mockClear() checkDaemonHealthMock.mockClear() checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 8c8b805740f..341fcd26df7 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA /** Handle the fake spawner hands back from ensureRunning/getHandle. */ export type MockSpawnerHandle = { mode?: 'degraded-new-pty-fallback' + adopted?: true releaseAdoptionLease?: () => void shutdown: () => Promise } @@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{ socketPath: string tokenPath: string mode?: 'degraded-new-pty-fallback' + adopted?: true }> /** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */ @@ -143,6 +145,7 @@ export type DaemonInitMockState = { rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void> trackDaemonReplacedMock: Mock<(...args: unknown[]) => void> trackDaemonRetiredMock: Mock<(...args: unknown[]) => void> + trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void> } /** net.connect stubs the suites install in beforeEach. */ diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index ceb7e9dfa69..423ee9ee34f 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { isPackagedMock, + getMacDaemonTccAttributionHealthMock, + trackDaemonAdoptedMock, probeSocketExistsMock, readFileSyncMock, unlinkSyncMock, @@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) vi.mock('./client', () => moduleFactories.client()) vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent()) vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) @@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() }) + // #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so + // it gets its own event — macOS only, and only for adopted (not freshly forked) daemons. + it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' }) + + await mod.initDaemonPtyProvider() + await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce()) + + // null pid record: the harness has no pid file, which the emitter classifies as 'unknown'. + expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2) + vi.restoreAllMocks() + }) + + it('does not report adoption for a freshly forked daemon or off macOS', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + await mod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const linuxMod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + await linuxMod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-test-harness.ts b/src/main/daemon/daemon-init-test-harness.ts index 6c38720a40b..6060ed9b759 100644 --- a/src/main/daemon/daemon-init-test-harness.ts +++ b/src/main/daemon/daemon-init-test-harness.ts @@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState { const rebindLocalProviderListenersMock = vi.fn() const trackDaemonReplacedMock = vi.fn() const trackDaemonRetiredMock = vi.fn() + const trackDaemonAdoptedMock = vi.fn() return { getPathMock, @@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } } diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index b59535a249a..21ff31918ac 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -41,6 +41,7 @@ function createPreservedDaemonHandle( mode?: 'degraded-new-pty-fallback' ): DaemonProcessHandle { const handle: DaemonProcessHandle = { + adopted: true, shutdown: async () => { await cleanupDaemonForProtocol(runtimeDir, protocolVersion) } diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 1881e276e97..fa794257bda 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -24,7 +24,10 @@ import { import type { DaemonProvider } from './daemon-provider-routing' import { installDaemonProvider } from './daemon-provider-state' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { trackDaemonAdopted } from './daemon-adoption-telemetry-event' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { trackDaemonRetired } from './daemon-lifecycle-event' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' import { DaemonPtyAdapter } from './daemon-pty-adapter' import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' @@ -156,9 +159,37 @@ export async function initDaemonPtyProvider( logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length }) + if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { + void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) + } await reconcileSeededClaudeLivePtys(routedAdapter) } +// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. +async function reportDaemonAdoption( + runtimeDir: string, + socketPath: string, + tokenPath: string, + adapter: DaemonPtyAdapter +): Promise { + try { + const [tccAttribution, liveSessionCount] = await Promise.all([ + getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath), + adapter.listSessions().then( + (sessions) => sessions.length, + () => null + ) + ]) + trackDaemonAdopted( + readDaemonPidRecord(getDaemonPidPath(runtimeDir)), + tccAttribution, + liveSessionCount + ) + } catch { + // Best-effort measurement only. + } +} + // Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { if (!hasSeededUnconfirmedClaudePtys()) { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 62c073f403e..235b286b0bc 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -4,6 +4,7 @@ import type { HistoryRecoveryContext, PendingDaemonSpawnOperation } from './daemon-pty-runtime-state' +import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event' import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { TerminalKilledError } from './daemon-pty-lifecycle-errors' import { DaemonPtySpawnResult } from './daemon-pty-spawn-result' @@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } activeSpawnContext = context const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + if (result.isNew && !attachOnly) { + trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath) + } return this.finishSpawn(context, result) } diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index a0376ef0fc0..8c50b764b05 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -31,6 +31,8 @@ export type DaemonPidFile = { export type DaemonProcessHandle = { mode?: 'degraded-new-pty-fallback' + /** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */ + adopted?: true releaseAdoptionLease?(): void shutdown(): Promise } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index b47b497fe43..83dadf5f5d2 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -161,7 +161,10 @@ export class DaemonTerminalAdmission { ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.cwdReadableByDaemon !== undefined + ? { cwdReadableByDaemon: result.cwdReadableByDaemon } + : {}) } } 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..52f773b1bf2 --- /dev/null +++ b/src/main/daemon/terminal-attach-cancellation.ts @@ -0,0 +1,20 @@ +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-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index aaaffaeb8e8..42f5bf457f4 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -54,4 +54,6 @@ export type CreateOrAttachResult = { attachToken: symbol incarnationId: PtyIncarnationId agentSessionEnsure?: AgentSessionClaimedSpawnResult + /** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */ + cwdReadableByDaemon?: boolean } diff --git a/src/main/daemon/terminal-host-cwd-readability.test.ts b/src/main/daemon/terminal-host-cwd-readability.test.ts new file mode 100644 index 00000000000..aa9e08379d7 --- /dev/null +++ b/src/main/daemon/terminal-host-cwd-readability.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' + +vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 99999, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExitCb?.(0), 5) + }), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => onExitCb?.(137)), + signal: vi.fn(), + onData() {}, + onExit(cb) { + onExitCb = cb + }, + dispose: vi.fn() + } +} + +// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict +// rides on the create result. A non-permission failure must never read as denial. +describe('TerminalHost cwd readability verdict', () => { + let host: TerminalHost + let platformDescriptor: PropertyDescriptor | undefined + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess() + host = new TerminalHost({ spawnSubprocess }) + }) + + afterEach(async () => { + await host.dispose() + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + }) + + const create = (sessionId: string, cwd?: string) => + host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + ...(cwd ? { cwd } : {}), + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + it('reports a readable cwd as readable', async () => { + expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true) + }) + + it('reports a missing cwd as readable — absence is not a permission denial', async () => { + expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true) + }) + + it('omits the verdict when no cwd was requested', async () => { + expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined() + }) + + it('omits the verdict on attach to an existing session', async () => { + await create('attach', process.cwd()) + const attached = await create('attach', process.cwd()) + expect(attached.isNew).toBe(false) + expect(attached.cwdReadableByDaemon).toBeUndefined() + }) +}) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index e1c8a22e750..8f6833c3d9f 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,3 +1,4 @@ +import { accessSync, constants as fsConstants } from 'node:fs' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' @@ -11,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'] @@ -30,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 @@ -88,6 +109,8 @@ async function spawnAndPublishSession( ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined } ): Promise { const { size, wslDistro } = ctx + // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. + const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null const subprocess = await deps.spawnSubprocess({ sessionId: opts.sessionId, cols: size.cols, @@ -184,6 +207,20 @@ async function spawnAndPublishSession( shellState: session.shellState, incarnationId: session.incarnationId, ...getDaemonSessionResultMetadata(session), + ...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}), attachToken: token } } + +// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what +// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never +// masquerade as a permission denial. +function isCwdReadableByThisProcess(cwd: string): boolean { + try { + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + return code !== 'EACCES' && code !== 'EPERM' + } +} 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/git/canonical-repo-key.test.ts b/src/main/git/canonical-repo-key.test.ts new file mode 100644 index 00000000000..87965bcaed6 --- /dev/null +++ b/src/main/git/canonical-repo-key.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetCanonicalRepoKeyCacheForTests, + getCanonicalRepoKey, + readGitCommonDir +} from './canonical-repo-key' + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('readGitCommonDir', () => { + it('reads the absolute answer modern Git gives', () => { + expect(readGitCommonDir('/repo/.git\n', '/repo/worktrees/a')).toBe('/repo/.git') + }) + + it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => { + // Without this every repository on such a host would answer `.git` and collide. + expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git') + }) + + it('resolves a WSL answer in Git execution space, not against the UNC path', () => { + expect(readGitCommonDir('.git\n', '//wsl$/Ubuntu/home/dev/repo')).toBe('/home/dev/repo/.git') + }) + + it('tolerates CRLF and blank lines', () => { + expect(readGitCommonDir('\r\n/repo/.git\r\n', '/repo')).toBe('/repo/.git') + }) + + it('returns undefined when Git printed nothing usable', () => { + expect(readGitCommonDir('\n', '/repo')).toBeUndefined() + }) +}) + +describe('getCanonicalRepoKey', () => { + it('gives every worktree of one repository the same key', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await expect(getCanonicalRepoKey('/repo')).resolves.toBe('local::/repo/.git') + await expect(getCanonicalRepoKey('/repo/worktrees/a')).resolves.toBe('local::/repo/.git') + }) + + it('scopes the key to the execution host', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/home/dev/repo/.git\n', stderr: '' }) + + await expect( + getCanonicalRepoKey('//wsl$/Ubuntu/home/dev/repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBe('wsl:Ubuntu::/home/dev/repo/.git') + }) + + it('caches so repeated arming costs no subprocess', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await getCanonicalRepoKey('/repo') + await getCanonicalRepoKey('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to the caller path when Git cannot answer', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('not a git repository')) + + await expect(getCanonicalRepoKey('/not-a-repo')).resolves.toBe('local::/not-a-repo') + }) +}) diff --git a/src/main/git/canonical-repo-key.ts b/src/main/git/canonical-repo-key.ts new file mode 100644 index 00000000000..1b06423bdc9 --- /dev/null +++ b/src/main/git/canonical-repo-key.ts @@ -0,0 +1,72 @@ +import { toWslExecutionSpace } from '../../shared/wsl-paths' +import { gitExecFileAsync } from './runner' +import { resolveRevParsePath } from './worktree-path-comparison' + +/** + * One repository on one execution host, named by its Git common dir. + * + * Shared by the fetch controller (which serializes fetches on it) and idle ref + * maintenance (which scopes all of its state to it), so both agree on what "the + * same repo" means across every worktree that points at it. + */ + +export type CanonicalRepoKeyOptions = { wslDistro?: string } + +const CACHE_MAX = 512 +const cache = new Map() + +/** + * Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag, + * exits 0, and prints a relative `.git`. Taking the raw stdout there would give + * every repository on the host the same key. + */ +export function readGitCommonDir(stdout: string, repoPath: string): string | undefined { + const commonDir = stdout + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .findLast((line) => line.length > 0 && !line.startsWith('-')) + return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined +} + +function remember(cacheKey: string, value: string): string { + cache.delete(cacheKey) + cache.set(cacheKey, value) + while (cache.size > CACHE_MAX) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } + return value +} + +/** `${runtimeKey}::${gitCommonDir}`, falling back to the caller's path. */ +export async function getCanonicalRepoKey( + repoPath: string, + options: CanonicalRepoKeyOptions = {} +): Promise { + const runtimeKey = options.wslDistro ? `wsl:${options.wslDistro}` : 'local' + const cacheKey = `${runtimeKey}::${repoPath}` + const cached = cache.get(cacheKey) + if (cached !== undefined) { + return remember(cacheKey, cached) + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd: repoPath, ...options } + ) + const commonDir = readGitCommonDir(stdout, repoPath) + if (commonDir) { + return remember(cacheKey, `${runtimeKey}::${commonDir}`) + } + } catch { + // The caller path remains a safe serialization key when canonicalization fails. + } + return remember(cacheKey, cacheKey) +} + +export function _resetCanonicalRepoKeyCacheForTests(): void { + cache.clear() +} 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 6b13cc718c5..96bb421b8e8 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = { type ExactRefPresence = 'present' | 'absent' | 'unknown' const EXACT_REF_PROBE_CONCURRENCY = 8 +// SHA-1 and SHA-256 repositories both report a full object id here. +const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/ export function isShowRefNoMatchError(error: unknown): boolean { const record = error && typeof error === 'object' ? (error as Record) : undefined @@ -126,3 +128,50 @@ export async function probeAnyExactRef( await Promise.all(Array.from({ length: workerCount }, () => probeNext())) return { found, unknown } } + +/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */ +export type ExactRefProbeStdinExec = ( + argv: string[], + options: ExactRefProbeExecOptions & { stdin: string } +) => Promise<{ stdout: string }> + +/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data + * rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`. + * A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */ +export async function probeAnyExactRefBatched( + runGit: ExactRefProbeStdinExec, + refs: readonly string[], + options: ExactRefProbeExecOptions = {} +): Promise<{ found: boolean; unknown: boolean }> { + const uniqueRefs = [...new Set(refs)] + const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref)) + if (safeRefs.length === 0) { + return { found: false, unknown: uniqueRefs.length > 0 } + } + let stdout: string + try { + ;({ stdout } = await runGit(['cat-file', '--batch-check'], { + ...options, + stdin: `${safeRefs.join('\n')}\n` + })) + } catch { + return { found: false, unknown: true } + } + const lines = stdout.split('\n').filter((line) => line.trim().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 } + } + let unknown = safeRefs.length !== uniqueRefs.length + for (const line of lines) { + const [head, type] = line.split(' ') + if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') { + return { found: true, unknown: false } + } + if (type !== 'missing') { + // `ambiguous`, or a spelling this Git reports differently; neither proves absence. + unknown = true + } + } + return { found: false, unknown } +} 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/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts new file mode 100644 index 00000000000..f6a411733c3 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const readRepoCommonDirFromGitMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./worktree-list-reader', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + readRepoCommonDirFromGit: readRepoCommonDirFromGitMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from './canonical-repo-key' +import { + _resetLocalRepoRefMaintenanceForTests, + armLocalRepoRefMaintenance, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' + +const NO_ABORT = new AbortController().signal + +function target(wslDistro?: string): ReturnType { + return createLocalRepoRefMaintenanceTarget({ + key: 'local::/repo/.git', + repoPath: wslDistro ? '//wsl$/Ubuntu/home/dev/repo' : '/repo', + ...(wslDistro ? { wslDistro } : {}) + }) +} + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() + readRepoCommonDirFromGitMock.mockReset() + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetCanonicalRepoKeyCacheForTests() + _resetLocalRepoRefMaintenanceForTests() +}) + +afterEach(() => { + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetLocalRepoRefMaintenanceForTests() + vi.restoreAllMocks() +}) + +describe('local repo ref maintenance target', () => { + it('never hands the pack child an abort signal', async () => { + // Killing a `pack-refs` strands a `refs/**` lock about one time in five, and + // on Windows a force-kill inside the rewrite strands `packed-refs.lock` + // every time. The child must always be allowed to finish. + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + const packCall = gitExecFileAsyncMock.mock.calls.find( + ([argv]) => (argv as string[])[0] === 'pack-refs' + ) + expect(packCall?.[1]).not.toHaveProperty('signal') + }) + + it('runs pack-refs at the background tier with a long deadline', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['pack-refs', '--all', '--prune'], + expect.objectContaining({ cwd: '/repo', admissionTier: 'background', timeout: 15 * 60_000 }) + ) + }) + + it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => { + for (const stdout of [ + 'maintenance.auto false\n', + 'gc.auto 0\n', + 'gc.auto 6700\nmaintenance.auto false\n' + ]) { + gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true) + } + + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'maintenance.auto true\ngc.auto 6700\n', + stderr: '' + }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + + // `git config --get-regexp` exits non-zero when nothing matches. + gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1')) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + }) + + it('walks the POSIX refs directory for a native repo', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBe('/repo/.git/refs') + }) + + it('translates a WSL repo answer back to the UNC path the main process can open', async () => { + // Git answers in its own execution space, which for WSL is a Linux path. + readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git') + + await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo\\.git\\refs' + ) + }) + + it('reports an unresolvable repository rather than guessing a path', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue(undefined) + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBeUndefined() + }) +}) + +describe('local repo ref maintenance scheduling', () => { + it('schedules nothing when the kill switch is set', () => { + process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE = '1' + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).not.toHaveBeenCalled() + }) + + it('arms through the shared single-flight instance otherwise', () => { + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).toHaveBeenCalledTimes(1) + }) + + it('is free when nothing has ever been armed', async () => { + // The common case by far: no timers, no instance, no reason to pay anything. + await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe( + 'done' + ) + }) + + it('holds the window shut for the duration of ref-touching work', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: 1, looseRefThreshold: 0 }) + setRepoMaintenanceActivityProbe(() => false) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + + await withRepoRefMaintenancePaused('branch-delete', async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(packRefs).not.toHaveBeenCalled() + }) + + await vi.waitFor(() => expect(packRefs).toHaveBeenCalledTimes(1)) + }) + + it('routes the app activity probe into the shared instance', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + let busy = true + setRepoMaintenanceActivityProbe(() => busy) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + await maintenance.whenAttemptSettled() + + expect(packRefs).not.toHaveBeenCalled() + busy = false + }) +}) diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts new file mode 100644 index 00000000000..e84f7d1ae30 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -0,0 +1,272 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + PACK_REFS_ARGS, + PACK_REFS_TIMEOUT_MS, + RefMaintenanceRepoLocked, + type PackedRefsLockReporter, + type RepoRefMaintenanceOptions, + type RepoRefMaintenanceTarget +} from '../../shared/repo-ref-maintenance-policy' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { withSpan } from '../observability/tracer' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' +import { gitExecFileAsync } from './runner' +import { readRepoCommonDirFromGit } from './worktree-list-reader' + +/** + * Main-process wiring for idle loose-ref packing on the local execution host + * (native and WSL). + * + * SSH-hosted repos are deliberately out of scope: the execution host owns + * anything that touches execution, so maintaining them means running host-side + * on the relay, which today has neither admission control nor spans. Keying all + * state by execution host is what keeps this path from reaching across. + */ + +export type RepoMaintenanceActivityProbe = () => boolean + +const REPO_BUSY_PROBE_MAX = 64 + +let activityProbe: RepoMaintenanceActivityProbe | null = null +let shared: RepoRefMaintenance | null = null +// Why keyed here rather than captured in the target: a repo can be armed from +// the fetch controller or from a user-initiated fetch, and every arming must see +// the same "this repo has work in flight" answer, not whichever closure was last. +const repoBusyProbes = new Map boolean>() + +/** Register the owner of "this repo has a fetch in flight" for `key`. */ +export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean): void { + repoBusyProbes.delete(key) + repoBusyProbes.set(key, probe) + while (repoBusyProbes.size > REPO_BUSY_PROBE_MAX) { + const oldest = repoBusyProbes.keys().next() + if (oldest.done) { + break + } + repoBusyProbes.delete(oldest.value) + } +} + +/** + * Register the app-wide "do not start maintenance now" signal. Owned by the + * main entry point because the inputs (live agents, battery, quit) are not + * visible from the git layer. + */ +export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void { + activityProbe = probe +} + +/** Support escape hatch: kills the sweep without touching the user's git config. */ +function isDisabled(): boolean { + return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1' +} + +function localMaintenanceOptions(): RepoRefMaintenanceOptions { + return { + // Fail closed: without the app-level gate installed we cannot see agents, + // creates, or battery, and running blind is worse than not running. + isBusy: () => activityProbe?.() ?? true, + observe: (attempt) => + withSpan('repo.ref_maintenance', (span) => attempt(span), { + attributes: { kind: 'git', 'repo.maintenance_host': 'local' } + }), + onError: (error) => { + console.warn('[repo-ref-maintenance] attempt failed:', error) + } + } +} + +export function getLocalRepoRefMaintenance(): RepoRefMaintenance { + shared ??= new RepoRefMaintenance(localMaintenanceOptions()) + return shared +} + +/** + * Cancels every armed timer and waits out any `packed-refs` rewrite in progress. + * + * Deliberately does not kill the child. A pack orphaned by the app quitting + * finishes on its own; a pack signalled mid-prune strands a ref lock about one + * time in five, and on Windows a force-kill inside the rewrite strands + * `packed-refs.lock` every time -- which blocks every later ref deletion. + */ +export function disposeLocalRepoRefMaintenance(): Promise { + const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve() + shared?.dispose() + shared = null + repoBusyProbes.clear() + return settling +} + +/** + * Hold every repository open while `run` touches refs. + * + * A ref deletion needs `packed-refs.lock`, which a running pack holds only while + * it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the + * collision into a short pause. Cancelling the pack instead would strand a + * `refs/**` lock about one time in five, which Git never clears, so the ref + * stays undeletable indefinitely. + */ +export async function withRepoRefMaintenancePaused( + reason: string, + run: () => Promise +): Promise { + // Taken unconditionally rather than only when something is already armed: a + // fetch inside `run` can arm the sweep, and one counter bump against an idle + // instance costs a microtask. This can rebuild the instance after the + // quit-time dispose; harmless, because a fresh one has no armed timers and its + // activity probe is gone, so it fails closed. + const release = await getLocalRepoRefMaintenance().pause(reason) + try { + return await run() + } finally { + release() + } +} + +/** Wait out a `packed-refs` rewrite without holding the window open. For shutdown. */ +export function awaitPackedRefsLockRelease(): Promise { + return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve() +} + +/** + * Count user-initiated ref work as activity and restart every armed countdown. + * + * Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a + * path the user is waiting on, and a manual fetch or pull says the user is at + * the keyboard, which is a reason to defer every repository. + */ +export function postponeRepoRefMaintenance(): void { + shared?.postponeAll() +} + +/** `overrides` preseeds the shared instance so a test can shorten the quiet period. */ +export function _resetLocalRepoRefMaintenanceForTests( + overrides?: Partial +): void { + shared?.dispose() + shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null + activityProbe = null + repoBusyProbes.clear() +} + +/** + * Git reports the common dir in its own execution space, so a WSL repo answers + * with a Linux path the Windows main process cannot open. Translate it back to + * the UNC spelling for the dirent walk; the walk reads directories, not files, + * so the handful of round trips stays cheap even over the share. + */ +function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string { + if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) { + return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs') + } + // Decided by path syntax, not by platform: `win32.isAbsolute` accepts POSIX paths too. + return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs') +} + +/** + * `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for + * to tell Git to stop maintaining a repository on its own. Orca sets both on its + * own fetches, but only as per-invocation `-c` flags, so this probe sees the + * user's persisted config and never Orca's own suppression. + */ +export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { + return configOutput + .split('\n') + .map((line) => line.trim()) + .some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0') +} + +/** + * The common dir in the spelling the main process can open. + * + * Derived from the converted refs path, not the raw one: a WSL answer arrives as + * a Linux path but converts to a UNC path with no `/` in it, so choosing the + * path flavour before conversion collapses the whole thing to `.`. + */ +function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string { + const refs = refsDirectoryForMainProcess(commonDir, wslDistro) + return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs) +} + +export type LocalRepoRefMaintenanceTargetArgs = { + /** `${runtimeKey}::${gitCommonDir}` -- already scoped to the execution host. */ + readonly key: string + readonly repoPath: string + readonly wslDistro?: string +} + +/** + * Record a write to this repo and restart its quiet-period countdown. The only + * entry point callers need: the kill switch is honoured before anything is + * scheduled, so a disabled build arms no timers at all. + */ +export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void { + if (isDisabled()) { + return + } + getLocalRepoRefMaintenance().arm(createLocalRepoRefMaintenanceTarget(args)) +} + +export function createLocalRepoRefMaintenanceTarget( + args: LocalRepoRefMaintenanceTargetArgs +): RepoRefMaintenanceTarget { + const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {} + // The engine always probes before it packs, so the pack reuses this answer + // rather than spending a second rev-parse on the same repository. + let commonDir: string | undefined + const resolveCommonDir = async (signal?: AbortSignal): Promise => { + commonDir ??= await readRepoCommonDirFromGit(args.repoPath, { + ...gitOptions, + ...(signal ? { signal } : {}) + }) + return commonDir + } + return { + key: args.key, + isBusy: () => repoBusyProbes.get(args.key)?.() ?? false, + async resolveRefsDirectory(signal: AbortSignal) { + const resolved = await resolveCommonDir(signal) + return resolved ? refsDirectoryForMainProcess(resolved, args.wslDistro) : undefined + }, + async isOptedOut(signal: AbortSignal) { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--get-regexp', '^(maintenance\\.auto|gc\\.auto)$'], + { cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal } + ) + return isGitAutoMaintenanceDisabled(stdout) + } catch { + // Neither key set is the common case and exits non-zero; that is consent. + return false + } + }, + async packRefs(lock: PackedRefsLockReporter) { + const resolved = await resolveCommonDir() + const owner = resolved + ? new PackRefsLockOwnership(gitCommonDirForMainProcess(resolved, args.wslDistro)) + : null + const claim = owner ? await owner.claim() : { ok: true as const } + if (!claim.ok) { + throw new RefMaintenanceRepoLocked(claim.reason) + } + // Report the rewrite window rather than accepting a signal. A pack that is + // killed mid-prune strands a `refs/**` lock about one time in five, and + // Git never clears those; waiting out the window costs at most ~1.4s. + const watch = owner?.watchLock((held) => lock.setHeld(held)) + try { + await gitExecFileAsync([...PACK_REFS_ARGS], { + cwd: args.repoPath, + ...gitOptions, + admissionTier: 'background', + timeout: PACK_REFS_TIMEOUT_MS + }) + } finally { + watch?.stop() + lock.setHeld(false) + await owner?.release() + } + } + } +} diff --git a/src/main/git/pack-refs-lock-ownership.test.ts b/src/main/git/pack-refs-lock-ownership.test.ts new file mode 100644 index 00000000000..e181feb9976 --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.test.ts @@ -0,0 +1,192 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' + +const roots: string[] = [] + +async function gitCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-pack-refs-lock-')) + roots.push(root) + return root +} + +function paths(commonDir: string): { lock: string; marker: string } { + return { + lock: join(commonDir, 'packed-refs.lock'), + marker: join(commonDir, 'packed-refs.orca-owner') + } +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** A pid that cannot be running: the kernel rejects it outright. */ +const DEAD_PID = 0x7fffffff +const ABANDONED_LOCK_AGE_MS = 15 * 60_000 +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** `claim` takes `now`, so age cases need no sleeping and no mtime forgery. */ +function laterBy(ms: number): number { + return Date.now() + ms +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('packed-refs lock ownership', () => { + it('claims a repository with no lock and records the owner', async () => { + const commonDir = await gitCommonDir() + const { marker } = paths(commonDir) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('drops the owner marker on release', async () => { + const commonDir = await gitCommonDir() + const ownership = new PackRefsLockOwnership(commonDir) + await ownership.claim() + + await ownership.release() + + await expect(exists(paths(commonDir).marker)).resolves.toBe(false) + }) + + it('refuses a lock it cannot prove is its own', async () => { + const commonDir = await gitCommonDir() + // A lock with no marker belongs to the user's own git, or to another tool. + await writeFile(paths(commonDir).lock, 'someone else') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(paths(commonDir).lock)).resolves.toBe(true) + }) + + it('refuses a lock whose recorded owner is still running', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('reclaims the lock its own dead process left behind', async () => { + // SIGKILL and power loss bypass git's cleanup, and git never clears this itself. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + + const claimed = await new PackRefsLockOwnership(commonDir).claim( + laterBy(ABANDONED_LOCK_AGE_MS + 1) + ) + + expect(claimed).toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('leaves a young lock alone even when the marker names a dead process', async () => { + // A marker outlives its lock, so a foreign lock can appear after our death. + // Age is the only thing separating our wreckage from somebody's live lock. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + await writeFile(lock, 'a different git process, started just now') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('does not wedge a repository forever when the recorded pid was recycled', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + // Our own pid stands in for a recycled one: alive, but not the process that wrote this. + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + }) + + it('refuses a lock whose marker is unreadable rather than guessing', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, 'not json') + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('claims cleanly when a marker outlived its lock', async () => { + const commonDir = await gitCommonDir() + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + }) +}) + +describe('stranded per-ref locks', () => { + it('clears the empty refs/**/*.lock files its own dead process left behind', async () => { + // `tempfile.c` opens the lock O_EXCL before linking it into the list the + // signal handler walks, so a kill in that window leaves a 0-byte file that + // Git never clears -- and `update-ref -d` on that ref then fails forever. + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'main.lock'), '') + await writeFile(join(namespace, 'main'), 'a'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false) + // The ref itself is untouched. + await expect(exists(join(namespace, 'main'))).resolves.toBe(true) + }) + + it('leaves a non-empty ref lock alone, because a live writer is mid-write', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'busy.lock'), 'b'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'busy.lock'))).resolves.toBe(true) + }) + + it('leaves ref locks alone when there is no marker naming a dead process', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'other.lock'), '') + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'other.lock'))).resolves.toBe(true) + }) +}) diff --git a/src/main/git/pack-refs-lock-ownership.ts b/src/main/git/pack-refs-lock-ownership.ts new file mode 100644 index 00000000000..9e7273b143b --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.ts @@ -0,0 +1,202 @@ +import { readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { + PACK_REFS_TIMEOUT_MS, + PACKED_REFS_LOCK_POLL_MS +} from '../../shared/repo-ref-maintenance-policy' + +/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */ +const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS + +/** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */ +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** The ref tree is wide but shallow; this only stops a pathological walk. */ +const REF_LOCK_SCAN_CEILING = 4096 + +/** + * Makes a `packed-refs.lock` Orca left behind attributable, and only that one. + * + * Git registers signal handlers that clean the lock up, but SIGKILL and power + * loss bypass them, and Git never removes a stale `packed-refs.lock` on its own + * -- every later ref deletion in that repository fails until someone deletes a + * file they have never heard of. Recording our pid beside the lock lets a later + * run recognise its own wreckage. + * + * Three independent conditions must all hold before anything is unlinked, + * because deleting a lock somebody else is holding is far worse than declining + * to pack: a marker must exist at all, the lock must be older than any + * `pack-refs` could legitimately run for, and the recorded process must be gone. + * A marker can outlive its lock, so age is what separates "our wreckage" from a + * foreign lock that happened to appear afterwards. + */ +export class PackRefsLockOwnership { + private readonly lockPath: string + private readonly markerPath: string + + constructor(gitCommonDir: string) { + const path = isWindowsAbsolutePathLike(gitCommonDir) ? win32 : posix + this.lockPath = path.join(gitCommonDir, 'packed-refs.lock') + this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner') + } + + /** Refused when the lock belongs to something we cannot prove is our own wreckage. */ + async claim(now = Date.now()): Promise { + const reclaim = await this.reclaimAbandonedLock(now) + if (!reclaim.ok) { + return reclaim + } + // Per-ref strands outlive their pack and are invisible to Git, which never + // clears a `refs/**\/*.lock` it did not create in this process. + await this.reclaimStrandedRefLocks(now) + try { + await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8') + } catch { + // Losing the marker only costs attribution on the next run, never correctness. + } + return { ok: true } + } + + /** + * Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite + * window opens and closes. Cheap: one `stat` on a fixed path. + */ + watchLock(report: (held: boolean) => void): { stop: () => void } { + let stopped = false + let last = false + const tick = async (): Promise => { + if (stopped) { + return + } + const held = (await fileAgeMs(this.lockPath, Date.now())) !== null + if (!stopped && held !== last) { + last = held + report(held) + } + } + const timer = setInterval(() => void tick(), PACKED_REFS_LOCK_POLL_MS) + timer.unref?.() + void tick() + return { + stop: () => { + stopped = true + clearInterval(timer) + } + } + } + + async release(): Promise { + await rm(this.markerPath, { force: true }).catch(() => {}) + } + + private async reclaimAbandonedLock(now: number): Promise { + const lockAgeMs = await fileAgeMs(this.lockPath, now) + if (lockAgeMs === null) { + return { ok: true } + } + // No marker means the lock is not ours to reason about, let alone remove. + const marker = await readOwnerMarker(this.markerPath) + if (marker === null) { + return { ok: false, reason: 'held by another process' } + } + if (lockAgeMs < ABANDONED_LOCK_AGE_MS) { + // Ours, but too young to be certain the writer is gone. Worth retrying soon. + return { ok: false, reason: 'our own lock, not yet old enough to reclaim' } + } + // Past the pid-reuse horizon the pid proves nothing, and a lock this old is + // abandoned whoever wrote it -- otherwise a recycled pid would wedge the + // repository permanently. + if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) { + return { ok: false, reason: 'the recorded owner is still running' } + } + await rm(this.lockPath, { force: true }).catch(() => {}) + await rm(this.markerPath, { force: true }).catch(() => {}) + return { ok: true } + } + + /** + * Clear `refs/**\/*.lock` files a dead pack of ours left behind. + * + * `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it + * into the list the signal handler walks, so a kill inside that window leaves + * a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref + * fail with `cannot lock ref ... File exists`, forever. Same three conditions + * as the packed-refs lock, plus a size check: a live writer's lock is not empty. + */ + private async reclaimStrandedRefLocks(now: number): Promise { + const marker = await readOwnerMarker(this.markerPath) + if (marker === null || isProcessAlive(marker.pid)) { + return + } + const markerAgeMs = await fileAgeMs(this.markerPath, now) + if (markerAgeMs === null || markerAgeMs < ABANDONED_LOCK_AGE_MS) { + return + } + const path = isWindowsAbsolutePathLike(this.markerPath) ? win32 : posix + const pending = [path.join(path.dirname(this.markerPath), 'refs')] + let visited = 0 + while (pending.length > 0) { + const directory = pending.pop() + if (directory === undefined || (visited += 1) > REF_LOCK_SCAN_CEILING) { + return + } + let entries: { name: string; isDirectory: () => boolean }[] + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) { + pending.push(full) + } else if (entry.name.endsWith('.lock') && (await isEmptyFile(full))) { + await rm(full, { force: true }).catch(() => {}) + } + } + } + } +} + +export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string } + +/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */ +async function isEmptyFile(path: string): Promise { + try { + return (await stat(path)).size === 0 + } catch { + return false + } +} + +async function readOwnerMarker(path: string): Promise<{ pid: number } | null> { + try { + const raw = (await readFile(path, 'utf-8')).slice(0, 256) + const pid = (JSON.parse(raw) as { pid?: unknown }).pid + return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? { pid } : null + } catch { + return null + } +} + +/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */ +async function fileAgeMs(path: string, now: number): Promise { + try { + return Math.max(0, now - (await stat(path)).mtimeMs) + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : 0 + } +} + +function isProcessAlive(pid: number): boolean { + if (pid === process.pid) { + return true + } + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index c1557bdd806..2baf3b77137 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -7,6 +7,10 @@ import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { + postponeRepoRefMaintenance, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' import { fetchForkRemoteWithStaleRefspecRepair } from './fork-remote-stale-branch-refspec' @@ -260,8 +264,11 @@ export async function gitPull( // Why: plain `git pull` uses the user's configured pull strategy (merge by // default) so diverged branches reconcile instead of erroring out. Conflicts // surface through the existing conflict-resolution flow. - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-pull', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + ) ) } @@ -270,9 +277,12 @@ export async function gitFastForward( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => - gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-fast-forward', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + ) ) ) } @@ -282,22 +292,28 @@ export async function gitFetch( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { + // `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a + // running idle pack holds while it rewrites -- ~1.4s at most. This is the user + // clicking Fetch, so wait that window out rather than letting it fail on the lock. + postponeRepoRefMaintenance() try { - if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget, options) - const runtimeOptions = gitOptionsForWorktree(worktreePath, options) - await fetchForkRemoteWithStaleRefspecRepair( - (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), - worktreePath, - target.remoteName, - () => - gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( - () => undefined - ) - ) - return - } - await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + await withRepoRefMaintenancePaused('git-fetch', async () => { + if (pushTarget) { + const target = await validateGitPushTarget(worktreePath, pushTarget, options) + const runtimeOptions = gitOptionsForWorktree(worktreePath, options) + await fetchForkRemoteWithStaleRefspecRepair( + (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), + worktreePath, + target.remoteName, + () => + gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( + () => undefined + ) + ) + return + } + await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + }) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } diff --git a/src/main/git/repo-branch-conflict-real-git.test.ts b/src/main/git/repo-branch-conflict-real-git.test.ts new file mode 100644 index 00000000000..34092273eda --- /dev/null +++ b/src/main/git/repo-branch-conflict-real-git.test.ts @@ -0,0 +1,49 @@ +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 { getBranchConflictKind } from './repo-branch-conflict' + +describe('branch conflict real Git contract', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('decides remote conflicts from one batched probe across many remotes', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-')) + 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', '--quiet', '-m', 'base') + const head = git('rev-parse', 'HEAD').trim() + + // Many remotes is the shape that used to cost one subprocess each. + for (let index = 0; index < 12; index += 1) { + git('remote', 'add', `remote${index}`, 'https://example.test/repo.git') + } + git('update-ref', 'refs/remotes/remote7/taken', head) + + await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote') + await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull() + // The allowed base ref is the one remote spelling that is not a conflict. + await expect( + getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken') + ).resolves.toBeNull() + + git('branch', 'local-only', head) + await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local') + }) +}) diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index dab8dd396d6..873c8bd3340 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => { expect(exec).not.toHaveBeenCalled() }) }) + +describe('getBranchConflictKindViaExec batched remote probe', () => { + function remoteNames(count: number): string { + return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n` + } + + function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> { + return async (argv) => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + } + + it('asks one batched child instead of one probe per remote', async () => { + const calls: string[][] = [] + const stdinPayloads: (string | undefined)[] = [] + const exec = baseExec(calls) + const batched = async ( + argv: string[], + options: { stdin: string } + ): Promise<{ stdout: string }> => { + calls.push(argv) + stdinPayloads.push(options.stdin) + return { + stdout: [ + 'refs/remotes/remote0/feature missing', + 'refs/remotes/remote1/feature missing', + 'refs/remotes/remote2/feature missing' + ].join('\n') + } + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls).toEqual([ + ['rev-parse', '--verify', 'refs/heads/feature'], + ['remote'], + ['cat-file', '--batch-check'] + ]) + expect(stdinPayloads).toEqual([ + 'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n' + ]) + }) + + it('reports a remote conflict from the batched answer', async () => { + const calls: string[][] = [] + const exec = baseExec(calls) + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: [ + 'refs/remotes/remote0/feature missing', + `${'a'.repeat(40)} commit 214`, + 'refs/remotes/remote2/feature missing' + ].join('\n') + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + }) + + it('falls back to per-ref probes when the batch cannot answer', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + if (argv[4] === 'refs/remotes/remote1/feature') { + return { stdout: 'abc refs/remotes/remote1/feature\n' } + } + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => { + throw new Error('cat-file is unavailable') + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) + + it('treats a short batch read as undecided rather than as absence', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: 'refs/remotes/remote0/feature missing' + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 162d5ef53b3..c799d3770be 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import { probeAnyExactRef, + probeAnyExactRefBatched, type ExactRefProbeExec, - type ExactRefProbeExecOptions + type ExactRefProbeExecOptions, + type ExactRefProbeStdinExec } from './exact-ref-probe' export type BranchConflictKind = 'local' | 'remote' @@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs( return [...refs] } +/** One batched child answers for every remote; the per-ref probes only run when the host cannot + * feed stdin, or when the batch came back undecided. */ +async function probeAnyRemoteConflictRef( + exec: ExactRefProbeExec, + batchedExec: ExactRefProbeStdinExec | undefined, + candidateRefs: readonly string[], + probeOptions: ExactRefProbeExecOptions +): Promise<{ found: boolean }> { + if (batchedExec) { + // A present ref is always decisive, so `found` never survives with `unknown` set. + const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions) + if (!batched.unknown) { + return { found: batched.found } + } + } + return probeAnyExactRef(exec, candidateRefs, probeOptions) +} + /** Run branch-conflict policy through the host that owns Git execution. */ export async function getBranchConflictKindViaExec( exec: ExactRefProbeExec, branchName: string, allowedBaseRef?: string, - options: ExactRefProbeExecOptions = {} + options: ExactRefProbeExecOptions = {}, + batchedExec?: ExactRefProbeStdinExec ): Promise { if (!canQueryRemoteBranchName(branchName)) { return null @@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec( return null } - const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions) + const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef( + exec, + batchedExec, + candidateRefs, + probeOptions + ) return hasRemoteConflict ? 'remote' : null } catch { @@ -119,15 +145,22 @@ export function getBranchConflictKind( options: LocalGitExecOptions = {} ): Promise { const execOptions = gitExecOptions(path, options) + const runLocalGit = ( + argv: string[], + commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + ): Promise<{ stdout: string }> => + gitExecFileAsync(argv, { + ...execOptions, + ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), + ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), + ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) + }) return getBranchConflictKindViaExec( - (argv, commandOptions) => - gitExecFileAsync(argv, { - ...execOptions, - ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), - ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }) - }), + runLocalGit, branchName, - allowedBaseRef + allowedBaseRef, + {}, + (argv, commandOptions) => runLocalGit(argv, commandOptions) ) } diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts new file mode 100644 index 00000000000..30c67b0365a --- /dev/null +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -0,0 +1,290 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { countLooseRefs } from '../../shared/loose-ref-count' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + _resetLocalRepoRefMaintenanceForTests, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './local-repo-ref-maintenance' +import { forceDeleteLocalBranch } from './worktree-branch-removal' + +const roots: string[] = [] +// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts +// three real `pack-refs` runs before the deferral budget is spent. +const QUIET_MS = 25 +const THRESHOLD = 20 + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +/** A repo whose only loose-ref backlog is the one the test asks for. */ +async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) + roots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'file.txt'), 'one\n') + git(repoPath, ['add', 'file.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + const head = git(repoPath, ['rev-parse', 'HEAD']) + // Written directly: `update-ref` for thousands of refs is the slow part of the fixture. + const namespace = join(repoPath, '.git', 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + for (let index = 0; index < looseRefs; index += 1) { + await writeFile(join(namespace, `branch-${index}`), `${head}\n`) + } + return { repoPath, refsDir: join(repoPath, '.git', 'refs') } +} + +function createMaintenance(onPackRefs: () => void = () => {}): { + maintenance: RepoRefMaintenance + arm: (repoPath: string) => void +} { + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + return { + maintenance, + arm: (repoPath: string) => { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + onPackRefs() + await target.packRefs(signal) + } + }) + } + } +} + +async function settle(maintenance: RepoRefMaintenance): Promise { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + await maintenance.whenAttemptSettled() +} + +/** Deferred repos re-arm for another quiet period, so drain rather than count rounds. */ +async function settleUntil( + maintenance: RepoRefMaintenance, + done: () => Promise +): Promise { + for (let round = 0; round < 100; round += 1) { + if (await done()) { + return + } + await settle(maintenance) + } +} + +afterEach(async () => { + _resetLocalRepoRefMaintenanceForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('idle ref maintenance against real Git', () => { + it('packs a backlogged repository down to zero loose refs', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + const { maintenance, arm } = createMaintenance() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false }) + // The refs survived the move into packed-refs; nothing was lost. + expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength( + THRESHOLD + 31 + ) + expect(git(repoPath, ['rev-parse', '--verify', 'refs/remotes/origin/branch-0'])).toMatch( + /^[0-9a-f]{40}$/ + ) + }, 30_000) + + it('leaves a healthy repository untouched', async () => { + const { repoPath, refsDir } = await createRepo(2) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ count: 3 }) + }, 30_000) + + it('honours maintenance.auto=false in the repository config', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + git(repoPath, ['config', 'maintenance.auto', 'false']) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + }, 30_000) + + it('runs one repository at a time even when several go quiet together', async () => { + const repos = await Promise.all([ + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5) + ]) + let concurrent = 0 + let peak = 0 + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + for (const { repoPath } of repos) { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + concurrent += 1 + peak = Math.max(peak, concurrent) + try { + await target.packRefs(signal) + } finally { + concurrent -= 1 + } + } + }) + } + + const allPacked = async (): Promise => { + const counts = await Promise.all(repos.map(({ refsDir }) => countLooseRefs(refsDir, 10_000))) + return counts.every((scan) => scan.count === 0) + } + await settleUntil(maintenance, allPacked) + maintenance.dispose() + + expect(peak).toBe(1) + for (const { refsDir } of repos) { + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ + count: 0, + saturated: false + }) + } + }, 60_000) +}) + +describe('yielding the repository to work that deletes refs', () => { + it('waits for the packed-refs lock and succeeds while the prune continues', async () => { + // The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s + // run; the rest is the prune, during which a concurrent `update-ref -d` + // succeeds on its own because per-ref locks last microseconds. Signalling + // the child there strands a `refs/**` lock Git never clears. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let packing = false + let releaseLock: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + packing = true + lock.setHeld(true) + // Stands in for the rewrite window, then the long prune that follows it. + await new Promise((resolve) => { + releaseLock = () => { + lock.setHeld(false) + resolve() + } + }) + } + }) + for (let attempt = 0; attempt < 200 && !packing; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + expect(packing).toBe(true) + + // The real deletion path, which routes through withRepoRefMaintenancePaused. + let deleted = false + const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => { + deleted = true + }) + + // It must still be waiting: the rewrite window is open. + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + expect(deleted).toBe(false) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed') + + // Releasing the window is enough -- the pack is never cancelled. + releaseLock?.() + await deletion + expect(deleted).toBe(true) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toBe('') + }, 30_000) + + it('does not block the caller once the rewrite window has closed', async () => { + // The prune phase is concurrency-safe, so a caller arriving during it pays + // nothing at all. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let pruning = false + let finishPrune: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + lock.setHeld(true) + lock.setHeld(false) + pruning = true + await new Promise((resolve) => { + finishPrune = resolve + }) + } + }) + for (let attempt = 0; attempt < 200 && !pruning; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + + const startedAt = Date.now() + await expect(forceDeleteLocalBranch(repoPath, 'doomed', head)).resolves.toBeUndefined() + expect(Date.now() - startedAt).toBeLessThan(2_000) + + finishPrune?.() + }, 30_000) +}) 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/worktree-add.ts b/src/main/git/worktree-add.ts index 3cd761f4d13..ea6ec704b46 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -4,6 +4,7 @@ import type { LocalBaseRefUpdateSuggestion } from '../../shared/worktree/base-ref-drift-types' import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -149,15 +150,17 @@ export async function addWorktree( options: AddWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performAddWorktree( - repoPath, - worktreePath, - branch, - baseBranch, - refreshLocalBaseRef, - noCheckout, - options + return await withRepoRefMaintenancePaused('worktree-add', () => + runWithGitReadCacheInvalidation(() => + performAddWorktree( + repoPath, + worktreePath, + branch, + baseBranch, + refreshLocalBaseRef, + noCheckout, + options + ) ) ) } finally { diff --git a/src/main/git/worktree-base-divergence-real-git.test.ts b/src/main/git/worktree-base-divergence-real-git.test.ts new file mode 100644 index 00000000000..e17192cf914 --- /dev/null +++ b/src/main/git/worktree-base-divergence-real-git.test.ts @@ -0,0 +1,99 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + measureRetargetDivergence, + RETARGET_MAX_COMMIT_DIVERGENCE +} from './worktree-base-divergence' + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +async function createRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-')) + tempRoots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + git(repoPath, ['add', 'version.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + return repoPath +} + +function commitEmpty(repoPath: string, count: number): void { + for (let index = 0; index < count; index += 1) { + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`]) + } +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('measureRetargetDivergence with real Git', () => { + it('allows the drift between a local branch and its remote-tracking copy', async () => { + const repoPath = await createRepo() + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commitEmpty(repoPath, 5) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3']) + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('within') + }) + + it('counts drift in both directions', async () => { + const repoPath = await createRepo() + const forkPoint = git(repoPath, ['rev-parse', 'HEAD']) + commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['reset', '--hard', '--quiet', forkPoint]) + commitEmpty(repoPath, 1) + + // 100 ahead + 1 behind is over the cap even though neither side alone exceeds it. + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + }) + + it('refuses a base that has drifted past the cap', async () => { + const repoPath = await createRepo() + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1) + + await expect( + measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main') + ).resolves.toBe('exceeded') + }) + + it('refuses unrelated histories, which share no commits at all', async () => { + const repoPath = await createRepo() + git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated']) + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root']) + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated') + ).resolves.toBe('exceeded') + }) + + it('reports an unreadable ref as unverifiable, not as excess drift', async () => { + const repoPath = await createRepo() + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing') + ).resolves.toBe('unknown') + }) +}) diff --git a/src/main/git/worktree-base-divergence.test.ts b/src/main/git/worktree-base-divergence.test.ts new file mode 100644 index 00000000000..88eec6a6b11 --- /dev/null +++ b/src/main/git/worktree-base-divergence.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() })) + +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) + +import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout' +import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' +import { + measureRetargetDivergence, + RETARGET_DIVERGENCE_BUDGET_MS +} from './worktree-base-divergence' + +type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal } + +function callOptions(): ExecOptions[] { + return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions) +} + +function subcommands(): string[] { + return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!) +} + +function answerProbes(count: string, mergeBase = 'abc123\n') { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => + args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count } + ) +} + +function exitCodeError(code: number): Error & { code: number } { + return Object.assign(new Error('git exited'), { code }) +} + +beforeEach(() => { + mocks.gitExecFileAsync.mockReset() +}) + +describe('measureRetargetDivergence deadlines', () => { + it('puts every probe under one shared budget, not a budget each', async () => { + answerProbes('3\n') + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('within') + + expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base']) + const signals = callOptions().map((options) => options.signal) + // One signal object across all three: the counts and merge-base are staged, so per-probe + // budgets would let the check cost the sum of them. + expect(new Set(signals).size).toBe(1) + expect(signals[0]).toBeInstanceOf(AbortSignal) + }) + + it('also gives each probe a command timeout well below git default read deadline', async () => { + answerProbes('3\n') + + await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + + // The signal covers admission queueing and the WSL environment wait, which start before a + // command timeout exists; the timeout still covers a hung spawn. + for (const options of callOptions()) { + expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS) + } + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) + }) + + it('really aborts the in-flight probes when the shared budget expires', async () => { + // A probe that behaves like a slow walk: it produces nothing on its own and only settles when + // its signal fires. If the budget never fired, or never reached the probe, this hangs and the + // test fails on its own timeout rather than passing on a signal that does nothing. + mocks.gitExecFileAsync.mockImplementation( + (_args: string[], options: ExecOptions) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => + reject( + Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + ), + { once: true } + ) + }) + ) + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', { + budgetMsForTest: 25 + }) + ).resolves.toBe('unknown') + }) + + it('clears the WSL read-environment wait, which starts before any command timeout', () => { + // Equal to it would make the first WSL-routed create of a session `unknown` by construction, + // and the WSL numbers meaningless. + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS) + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) + }) + + it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => { + mocks.gitExecFileAsync.mockImplementation( + (_args: string[], options: ExecOptions) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => + reject( + Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + ), + { once: true } + ) + }) + ) + const controller = new AbortController() + const pending = measureRetargetDivergence( + '/repo', + 'refs/heads/main', + 'refs/remotes/origin/main', + // A budget long enough that only the caller's cancellation can end this in time. + { signal: controller.signal, budgetMsForTest: 60_000 } + ) + controller.abort() + + await expect(pending).resolves.toBe('unknown') + }) + + it('reports a blown deadline as unverifiable rather than as excess drift', async () => { + mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.')) + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('unknown') + // A count that never answered must not go on to spend a merge-base walk. + expect(subcommands()).not.toContain('merge-base') + }) + + it('separates merge-base saying no from merge-base failing', async () => { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + if (args[0] === 'merge-base') { + // Exit 1 is Git's answer for unrelated histories. + throw exitCodeError(1) + } + return { stdout: '2\n' } + }) + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + + mocks.gitExecFileAsync.mockReset() + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + if (args[0] === 'merge-base') { + // A timeout carries no exit code and must not be read as "no common ancestor". + throw new Error('git timed out.') + } + return { stdout: '2\n' } + }) + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('unknown') + }) + + it('reports drift past the cap without spending a merge-base walk', async () => { + answerProbes('101\n') + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + expect(subcommands()).not.toContain('merge-base') + }) + + it('routes every probe to the caller-named WSL distro', async () => { + answerProbes('1\n') + + await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', { + wslDistro: 'Ubuntu' + }) + + for (const options of callOptions()) { + expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' }) + } + }) +}) diff --git a/src/main/git/worktree-base-divergence.ts b/src/main/git/worktree-base-divergence.ts new file mode 100644 index 00000000000..c7c924c2f24 --- /dev/null +++ b/src/main/git/worktree-base-divergence.ts @@ -0,0 +1,165 @@ +import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' +import { gitExecFileAsync } from './runner' + +export type RetargetDivergenceOptions = { + wslDistro?: string + /** The create's own cancellation signal. Without it a cancelled create leaves these probes + * running until the budget expires. */ + signal?: AbortSignal + /** Shortens only the end-to-end budget so a test can observe a real abort; production always + * uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */ + budgetMsForTest?: number +} + +/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could + * not be evaluated" have different causes and different fixes, and only the second one means a + * retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a + * cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */ +export type RetargetDivergence = 'within' | 'exceeded' | 'unknown' + +/** + * How far two bases may drift and still be worth retargeting a prepared checkout between. + * + * Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74 + * files apart, while an abandoned fork's `main` — same branch name, so the same base family — + * was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit + * count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff + * the retarget reset would have to write. + */ +export const RETARGET_MAX_COMMIT_DIVERGENCE = 100 + +/** + * Headroom for the walk itself, on top of the worst pre-spawn wait. + * + * ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a + * cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by + * construction. + */ +const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500 + +/** + * End-to-end deadline for the whole check, not per probe. + * + * Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe + * before a command timeout even exists, so a budget merely equal to it would guarantee `unknown` + * on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it + * keeps that relationship explicit instead of coincidental. + * + * Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold + * `worktree add`, so when it expires the create pays the budget and then does that add anyway. The + * total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`. + * + * It must be a signal, not just a per-command timeout, because a per-command timeout starts only + * after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the + * counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number + * written here. + */ +export const RETARGET_DIVERGENCE_BUDGET_MS = + WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS + +function probeOptions( + repoPath: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } { + // Built field by field rather than spread: the caller's bag carries a test-only key that must + // never reach git's exec options. + // Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a + // command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn. + return { + cwd: repoPath, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + signal, + timeout: RETARGET_DIVERGENCE_BUDGET_MS + } +} + +/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */ +async function countCommitsAhead( + repoPath: string, + fromRef: string, + toRef: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): Promise { + try { + // `--max-count` stops the walk, so an unrelated history costs a bounded number of commits + // rather than a full traversal. Both flags predate the Git 2.25 baseline. + // `--end-of-options` (Git 2.24) because a range whose left side began with `-` would + // otherwise parse as an option; callers only pass `refs/`-qualified names today, and this + // keeps that from being load-bearing. + const { stdout } = await gitExecFileAsync( + [ + 'rev-list', + '--count', + `--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`, + '--end-of-options', + `${fromRef}..${toRef}` + ], + probeOptions(repoPath, options, signal) + ) + const count = Number.parseInt(stdout.trim(), 10) + return Number.isNaN(count) ? null : count + } catch { + return null + } +} + +/** True/false when Git decided, null when the probe was unusable. */ +async function hasCommonHistory( + repoPath: string, + leftRef: string, + rightRef: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['merge-base', '--end-of-options', leftRef, rightRef], + probeOptions(repoPath, options, signal) + ) + return stdout.trim().length > 0 + } catch (error) { + // Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort + // carries no exit code and must not be read as one. + return (error as { code?: unknown }).code === 1 ? false : null + } +} + +/** + * Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap. + * + * Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes + * reusing the checkout, so every other outcome lands on the cold create path. + */ +export async function measureRetargetDivergence( + repoPath: string, + preparedBase: string, + targetBase: string, + options: RetargetDivergenceOptions = {} +): Promise { + const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS) + // Combined so cancelling the create stops the probes immediately rather than at the deadline. + const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget + // Both directions: commits the target adds decide what the reset writes, commits only the + // preparation has decide what it must delete. + const [ahead, behind] = await Promise.all([ + countCommitsAhead(repoPath, preparedBase, targetBase, options, signal), + countCommitsAhead(repoPath, targetBase, preparedBase, options, signal) + ]) + if (ahead === null || behind === null) { + return 'unknown' + } + if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) { + return 'exceeded' + } + // Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of + // them in full. Reaching here already proved neither side is more than the cap ahead of the + // other, which bounds that walk — and unrelated histories of any size fail the counts first. + // Required because unrelated histories replace the whole tree however few commits they carry. + const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal) + if (shareHistory === null) { + return 'unknown' + } + return shareHistory ? 'within' : 'exceeded' +} diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 8e44877ab76..87c761ebbcc 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef( return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } +/** + * The qualified ref a worktree base names in this repo, or the base unchanged when nothing + * matches. Callers that key on a base must compare this, not the raw string, or `main` and + * `refs/heads/main` look like different bases. + */ +export function resolveLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: GitExecOptions = {} +): Promise { + return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + ) +} + /** * Whether a worktree base — a qualified ref, a short branch or remote name, or a * full commit id — already resolves in this repo's own object/ref store. diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 5e64b4a582d..08b6b21a1e3 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-branch-cleanup' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { parseWorktreeList } from './worktree-list-parser' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' @@ -152,7 +153,11 @@ export async function forceDeleteLocalBranch( } // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. try { - await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + // `update-ref -d` needs the packed-refs lock a running idle pack holds while + // it rewrites; waits it out rather than cancelling the pack. + await withRepoRefMaintenancePaused('branch-delete', () => + runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + ) } catch { throw new Error( `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index bdb1e9fcc4b..63f8021a7ae 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => { expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) }) + it('lands a cross-base retarget on exactly the requested commit', async () => { + const { repoPath, root } = await createRepo() + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = join(preparationRoot, `${process.pid}-retarget`) + const finalPath = join(root, 'retargeted-worktree') + await mkdir(preparationRoot, { recursive: true }) + + await writeFile(join(repoPath, 'shared.txt'), 'kept\n') + git(repoPath, ['add', 'shared.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'local main']) + const localMainHead = git(repoPath, ['rev-parse', 'HEAD']) + + // A remote-tracking `main` that diverged: different content, an extra file, and one deletion. + git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main']) + await writeFile(join(repoPath, 'version.txt'), 'two\n') + await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n') + git(repoPath, ['rm', '--quiet', 'shared.txt']) + git(repoPath, ['add', 'version.txt', 'only-upstream.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'upstream main']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + git(repoPath, ['branch', '--quiet', '-D', 'upstream-main']) + + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/remotes/origin/main', + createWorktreePreparationLockReason('retarget-test') + ) + expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead) + + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main') + + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead) + // A retarget that left stale files behind would be a wrong checkout, not just a slow one. + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'one\n' + ) + expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'kept\n' + ) + await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow() + expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted') + expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe( + 'refs/heads/main' + ) + }) + it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => { const { repoPath, root } = await createRepo() const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 3be0fee1648..b60dc01ec33 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -10,6 +10,7 @@ import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -69,46 +70,48 @@ export async function prepareWorktreeCreateCheckout( options: GitWorktreeExecOptions = {} ): Promise { try { - await runWithGitReadCacheInvalidation(async () => { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) - try { - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'add', - '--detach', - '--no-checkout', - worktreePath, - effectiveBase - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + await withRepoRefMaintenancePaused('worktree-prepare', () => + runWithGitReadCacheInvalidation(async () => { + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) - // The add just wrote the marker; drop any pre-create route before the reset routes Git. - invalidateWslLinkedWorktreeGitRouting(worktreePath) - // Why: reset materializes files without running user post-checkout hooks before submit. - await gitExecFileAsync( - [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], - { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'lock', - '--reason', - lockReason, - worktreePath - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - } catch (error) { - await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) - throw error - } - }) + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + worktreePath, + effectiveBase + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + // The add just wrote the marker; drop any pre-create route before the reset routes Git. + invalidateWslLinkedWorktreeGitRouting(worktreePath) + // Why: reset materializes files without running user post-checkout hooks before submit. + await gitExecFileAsync( + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'lock', + '--reason', + lockReason, + worktreePath + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + } catch (error) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + throw error + } + }) + ) } finally { notifyPreparedWorktreeMutation(repoPath) } diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts index 6abb5a099c6..a60819181d4 100644 --- a/src/main/git/worktree-listing.ts +++ b/src/main/git/worktree-listing.ts @@ -97,7 +97,7 @@ export async function listWorktreesStrict( return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options) } -async function annotateSparseCheckoutStatus( +export async function annotateSparseCheckoutStatus( repoPath: string, worktrees: GitWorktreeInfo[], options: GitWorktreeExecOptions = {} diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index c6743a4a7e2..afe1bf2a9c1 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -22,6 +22,7 @@ import { } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' @@ -36,8 +37,13 @@ export async function removeWorktree( options: RemoveWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performRemoveWorktree(repoPath, worktreePath, force, options) + // Removal deletes branches, and a ref deletion needs the packed-refs lock a + // running idle pack holds while it rewrites. Waits that window out; the + // prune phase that follows it is concurrency-safe and is left to finish. + return await withRepoRefMaintenancePaused('worktree-remove', () => + runWithGitReadCacheInvalidation(() => + performRemoveWorktree(repoPath, worktreePath, force, options) + ) ) } finally { invalidateWslLinkedWorktreeGitRouting(worktreePath) diff --git a/src/main/git/worktree-scan-cache-annotation-reuse.test.ts b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts new file mode 100644 index 00000000000..0fabe5ba049 --- /dev/null +++ b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts @@ -0,0 +1,93 @@ +// The annotated listing is the graph listing plus a sparse probe: callers that read only +// `worktree.path` must skip the probe, without costing a second `git worktree list`. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../../shared/worktree/types' + +const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } = + vi.hoisted(() => ({ + detectSparseCheckoutMock: vi.fn(), + readWorktreeListMock: vi.fn(), + readTranslatedWorktreeGraphMock: vi.fn() + })) + +vi.mock('./worktree-sparse-state', () => ({ + detectSparseCheckout: detectSparseCheckoutMock, + resolveGitCommonDir: vi.fn() +})) +vi.mock('./worktree-list-reader', () => ({ + readCheckedOutBranchRef: vi.fn(), + readRepoCommonDirFromGit: vi.fn(), + readRepoLocation: vi.fn(), + readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock, + readWorktreeHeadOid: vi.fn(), + readWorktreeList: readWorktreeListMock +})) + +import { _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees } from './worktree' +import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' + +const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const ROW: GitWorktreeInfo = { + path: 'C:\\wt\\x', + head: 'a'.repeat(40), + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false +} + +describe('graph and annotated worktree scans', () => { + beforeEach(() => { + detectSparseCheckoutMock.mockReset() + detectSparseCheckoutMock.mockResolvedValue(true) + readWorktreeListMock.mockReset() + readWorktreeListMock.mockResolvedValue([ROW]) + readTranslatedWorktreeGraphMock.mockReset() + readTranslatedWorktreeGraphMock.mockResolvedValue([ROW]) + _resetWorktreeScanCacheForTests() + __resetSparseCheckoutStateCacheForTests() + }) + + it('does not probe sparse state for a graph scan', async () => { + const rows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }) + + expect(rows[0]?.path).toBe('C:\\wt\\x') + expect(rows[0]?.isSparse).toBeUndefined() + expect(detectSparseCheckoutMock).not.toHaveBeenCalled() + }) + + it('still probes sparse state for the annotated scan', async () => { + const rows = await listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + + expect(rows[0]?.isSparse).toBe(true) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('reads the git listing once for an overlapping graph and annotated scan', async () => { + const [graphRows, annotatedRows] = await Promise.all([ + listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }), + listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + ]) + + expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(1) + expect(graphRows[0]?.isSparse).toBeUndefined() + expect(annotatedRows[0]?.isSparse).toBe(true) + }) + + // Sharing the listing must not make the probe-free caller wait on the probe it opted out of. + it('resolves a graph scan while the annotated scan is still probing', async () => { + let releaseProbe!: () => void + detectSparseCheckoutMock.mockImplementation( + () => + new Promise((resolve) => { + releaseProbe = () => resolve(true) + }) + ) + + const annotatedScan = listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + const graphRows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }) + + expect(graphRows[0]?.path).toBe('C:\\wt\\x') + releaseProbe() + expect((await annotatedScan)[0]?.isSparse).toBe(true) + }) +}) diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index d64ec2d4852..2a687420cd8 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -98,7 +98,9 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('keeps graph and annotated scans separate despite sharing the same Git listing', async () => { + // The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree + // list` and only the annotated caller pays the probe. They ran Git twice before. + it('runs one git listing for concurrent graph and annotated scans', async () => { const resolvers: ((value: { stdout: string }) => void)[] = [] gitExecFileAsyncMock.mockImplementation( () => @@ -109,13 +111,34 @@ describe('listWorktrees in-flight sharing', () => { const graphScan = listWorktreeGraph('/repo') const annotatedScan = listWorktrees('/repo') - expect(resolvers).toHaveLength(2) + expect(resolvers).toHaveLength(1) for (const resolve of resolvers) { resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' }) } await Promise.all([graphScan, annotatedScan]) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + // Order must not matter: whichever runs first owns the listing and the other joins it. + it('runs one git listing when the annotated scan starts first', async () => { + const resolvers: ((value: { stdout: string }) => void)[] = [] + gitExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + + const annotatedScan = listWorktrees('/repo') + const graphScan = listWorktreeGraph('/repo') + expect(resolvers).toHaveLength(1) + + for (const resolve of resolvers) { + resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' }) + } + await Promise.all([annotatedScan, graphScan]) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('keeps graph scans with an AbortSignal isolated from shared callers', async () => { @@ -352,14 +375,15 @@ describe('listWorktrees in-flight sharing', () => { expect(scanResolvers).toHaveLength(1) await moveWorktree('/repo', '/repo-old', '/repo-new') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + // Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers. + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) const freshScan = listWorktrees('/repo') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 }) scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') expect((await freshScan)[0]?.path).toBe('/repo-new') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') expect((await staleScan)[0]?.path).toBe('/repo') @@ -396,7 +420,7 @@ describe('listWorktrees in-flight sharing', () => { const newestScan = listWorktrees('/repo') expect(listCalls).toBe(3) - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 }) scanResolvers[0]?.() scanResolvers[2]?.() diff --git a/src/main/git/worktree-scan-cache.ts b/src/main/git/worktree-scan-cache.ts index 327aead5f2e..59a7691fe7f 100644 --- a/src/main/git/worktree-scan-cache.ts +++ b/src/main/git/worktree-scan-cache.ts @@ -1,8 +1,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { + annotateSparseCheckoutStatus, listWorktreeGraph as listWorktreeGraphUnshared, - listWorktreesStrict as listWorktreesStrictUnshared, - listWorktreesUnshared + listWorktreesStrict as listWorktreesStrictUnshared } from './worktree-listing' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options' @@ -90,6 +90,22 @@ function shareWorktreeScan( return scan } +/** + * Sparse annotation layered over the shared graph scan rather than its own `git worktree list`. + * + * Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the + * per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes + * without costing a second subprocess when it overlaps a badge reader — the two ran Git twice + * before. Strict stays on its own scan because it must be able to reject. + */ +async function runAnnotatedWorktreeScan( + repoPath: string, + options: GitWorktreeExecOptions +): Promise { + const worktrees = await listWorktreeGraph(repoPath, options) + return annotateSparseCheckoutStatus(repoPath, worktrees, options) +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -99,7 +115,7 @@ export function listWorktrees( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - return shareWorktreeScan(repoPath, options, 'lenient', listWorktreesUnshared) + return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan) } /** 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/index.ts b/src/main/index.ts index acf913b2c7a..522e59b908f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' +import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.skillShareDeepLinks.capture(argv, (shareId) => { state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) + state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles) // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return @@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.desktopActivationGate?.requestActivation() } +/** + * Hands buffered OS-opened markdown paths to a renderer that has proven it is listening. + * + * Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer + * with no listener attached is dropped silently and the queue would be gone. + */ +function publishOsOpenedMarkdownFiles(): void { + const targetWindow = state.mainWindow + if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) { + return + } + // Why consumed before the await: a renderer pull racing this resolve must not take the same + // batch again. The restore() calls hand it back if delivery turns out to be impossible. + const filePaths = state.osOpenedMarkdownFiles.consume() + if (filePaths.length === 0) { + return + } + void resolveOpenedMarkdownDocuments(filePaths) + .then((documents) => { + if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) { + state.osOpenedMarkdownFiles.restore(filePaths) + return + } + if (documents.length > 0) { + targetWindow.webContents.send('ui:openMarkdownFiles', documents) + } + }) + .catch((error) => { + state.osOpenedMarkdownFiles.restore(filePaths) + console.warn('[os-open] Failed to resolve OS-opened markdown files:', error) + }) +} + const handleMacAppActivation = createMacAppActivationHandler({ getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation @@ -53,7 +88,22 @@ if (preflightReady) { event.preventDefault() requestDesktopActivation([url]) }) + // Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler + // that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins. + app.on('open-file', (event, filePath) => { + if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) { + return + } + event.preventDefault() + // Why gated on isReady: pre-ready the cold-start window is already on its way, and + // activating the gate here would try to open one before Electron can. + if (app.isReady()) { + requestDesktopActivation() + } + }) state.skillShareDeepLinks.capture(process.argv) + // Why no publish: nothing is listening this early, so the first renderer pulls these on mount. + state.osOpenedMarkdownFiles.capture(process.argv) registerMainProcessIpcHandlers() installMainProcessQuitHandlers() void app.whenReady().then(async () => { diff --git a/src/main/ipc/cli-appimage-stale-registration.test.ts b/src/main/ipc/cli-appimage-stale-registration.test.ts new file mode 100644 index 00000000000..927c4e4e1b9 --- /dev/null +++ b/src/main/ipc/cli-appimage-stale-registration.test.ts @@ -0,0 +1,381 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + handle: vi.fn(), + hydrateShellPath: vi.fn(), + install: vi.fn(), + isAppImageRegistrationOwnedBySibling: vi.fn(), + mergePathSegments: vi.fn(), + remove: vi.fn(), + resolveAppImageCacheKey: vi.fn(), + resolveAppImageRuntimeIdentity: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) + +vi.mock('../cli/cli-installer', () => ({ + CliInstaller: class { + getStatus = mocks.getStatus + install = mocks.install + isAppImageRegistrationOwnedBySibling = mocks.isAppImageRegistrationOwnedBySibling + remove = mocks.remove + } +})) + +vi.mock('../appimage-runtime-identity', () => ({ + resolveAppImageRuntimeIdentity: mocks.resolveAppImageRuntimeIdentity +})) + +vi.mock('../cli/appimage-extracted-root', () => ({ + resolveAppImageCacheKey: mocks.resolveAppImageCacheKey +})) + +vi.mock('../cli/wsl-cli-installer', () => ({ + WslCliInstaller: class { + getStatus = mocks.getStatus + install = mocks.install + remove = mocks.remove + } +})) + +vi.mock('../cli/wsl-cli-registration-registry', () => ({ + recordWslCliRegistrationInstalled: vi.fn(), + recordWslCliRegistrationRemoved: vi.fn() +})) + +vi.mock('../cli/wsl-cli-registration-operation', () => ({ + runSerializedWslCliRegistrationOperation: vi.fn() +})) + +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: vi.fn() })) + +vi.mock('../startup/hydrate-shell-path', () => ({ + hydrateShellPath: mocks.hydrateShellPath, + mergePathSegments: mocks.mergePathSegments +})) + +vi.mock('../wsl', () => ({ getDefaultWslDistro: vi.fn() })) + +import { registerCliHandlers } from './cli' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function status( + state: CliInstallState, + launcherPath = '/cache/current/resources/bin/orca-ide' +): CliInstallStatus { + return { + platform: 'linux', + commandName: 'orca-ide', + commandPath: '/home/me/.local/bin/orca-ide', + pathDirectory: '/home/me/.local/bin', + pathConfigured: true, + launcherPath, + installMethod: 'symlink', + supported: true, + state, + currentTarget: state === 'not_installed' ? null : '/cache/old/resources/bin/orca-ide', + unsupportedReason: null, + detail: null + } +} + +function installStatusHandler(): () => Promise { + registerCliHandlers() + return cliHandler('cli:getInstallStatus') +} + +function cliHandler(channelName: string): () => Promise { + const call = mocks.handle.mock.calls.find(([channel]) => channel === channelName) + expect(call).toBeTruthy() + return call![1] +} + +beforeEach(() => { + mocks.getStatus.mockReset() + mocks.handle.mockReset() + mocks.hydrateShellPath.mockReset().mockResolvedValue({ ok: false }) + mocks.install.mockReset() + mocks.isAppImageRegistrationOwnedBySibling.mockReset().mockReturnValue(false) + mocks.mergePathSegments.mockReset() + mocks.remove.mockReset() + mocks.resolveAppImageCacheKey.mockReset().mockReturnValue('generation-1') + mocks.resolveAppImageRuntimeIdentity.mockReset().mockImplementation(() => + process.platform === 'linux' + ? { + appImagePath: '/opt/Orca.AppImage' + } + : null + ) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + vi.stubEnv('APPIMAGE', '/opt/Orca.AppImage') +}) + +afterEach(() => { + vi.unstubAllEnvs() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.restoreAllMocks() +}) + +describe('AppImage CLI registration startup repair', () => { + it('repairs a managed stale registration and returns the installed status', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockResolvedValue(installed) + + await expect(installStatusHandler()()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it('keeps the stale status when automatic repair fails', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockRejectedValue(new Error('read-only filesystem')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(warn).toHaveBeenCalledWith( + '[cli] Failed to repair stale AppImage registration:', + 'read-only filesystem' + ) + }) + + it('retries a failed automatic repair after the cooldown', async () => { + const stale = status('stale') + const installed = status('installed') + let now = 1_000 + mocks.getStatus.mockResolvedValue(stale) + mocks.install + .mockRejectedValueOnce(new Error('read-only filesystem')) + .mockResolvedValueOnce(installed) + vi.spyOn(Date, 'now').mockImplementation(() => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(stale) + await expect(handler()).resolves.toBe(stale) + expect(mocks.install).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledOnce() + + now += 30_000 + await expect(handler()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('shares one automatic repair across concurrent status polls', async () => { + const stale = status('stale') + const installed = status('installed') + let finishRepair: (result: CliInstallStatus) => void = () => {} + const repair = new Promise((resolve) => { + finishRepair = resolve + }) + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockReturnValue(repair) + const handler = installStatusHandler() + + const first = handler() + const second = handler() + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + finishRepair(installed) + + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]) + }) + + it('repairs a later AppImage generation after an earlier repair succeeds', async () => { + const firstStale = status('stale', '/cache/launcher/orca-ide') + const firstInstalled = status('installed', '/cache/launcher/orca-ide') + const nextStale = status('stale', '/cache/launcher/orca-ide') + const nextInstalled = status('installed', '/cache/launcher/orca-ide') + mocks.getStatus + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + mocks.install.mockResolvedValueOnce(firstInstalled).mockResolvedValueOnce(nextInstalled) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(firstInstalled) + await expect(handler()).resolves.toBe(nextInstalled) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('waits for the cooldown before repairing a newer AppImage generation', async () => { + const firstStale = status('stale', '/cache/launcher/orca-ide') + const nextStale = status('stale', '/cache/launcher/orca-ide') + const nextInstalled = status('installed', '/cache/launcher/orca-ide') + let now = 1_000 + mocks.getStatus + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + mocks.install + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(nextInstalled) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + vi.spyOn(Date, 'now').mockImplementation(() => now) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(firstStale) + await expect(handler()).resolves.toBe(nextStale) + expect(mocks.install).toHaveBeenCalledOnce() + + now += 30_000 + await expect(handler()).resolves.toBe(nextInstalled) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('keeps the explicit install action retryable after automatic repair fails', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(installed) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerCliHandlers() + + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + await expect(cliHandler('cli:install')()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('serializes concurrent explicit installs', async () => { + const installed = status('installed') + let finishFirstInstall: (result: CliInstallStatus) => void = () => {} + const firstInstall = new Promise((resolve) => { + finishFirstInstall = resolve + }) + mocks.install.mockReturnValueOnce(firstInstall).mockResolvedValueOnce(installed) + registerCliHandlers() + const handler = cliHandler('cli:install') + + const first = handler() + const second = handler() + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + + finishFirstInstall(installed) + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('does not undo a queued removal with a stale automatic repair', async () => { + const stale = status('stale') + const notInstalled = status('not_installed') + let finishRemove: (result: CliInstallStatus) => void = () => {} + const removal = new Promise((resolve) => { + finishRemove = resolve + }) + mocks.getStatus.mockResolvedValueOnce(stale).mockResolvedValueOnce(notInstalled) + mocks.remove.mockReturnValue(removal) + registerCliHandlers() + + const remove = cliHandler('cli:remove')() + await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalledOnce()) + const poll = cliHandler('cli:getInstallStatus')() + finishRemove(notInstalled) + + await expect(remove).resolves.toBe(notInstalled) + await expect(poll).resolves.toBe(notInstalled) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('keys a queued repair cooldown to the generation it rechecks', async () => { + const stale = status('stale') + const notInstalled = status('not_installed') + let finishRemove: (result: CliInstallStatus) => void = () => {} + const removal = new Promise((resolve) => { + finishRemove = resolve + }) + mocks.getStatus.mockResolvedValue(stale) + mocks.remove.mockReturnValue(removal) + mocks.install.mockRejectedValue(new Error('temporary failure')) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValue('generation-2') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerCliHandlers() + + const remove = cliHandler('cli:remove')() + await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalledOnce()) + const oldGenerationPoll = cliHandler('cli:getInstallStatus')() + finishRemove(notInstalled) + + await expect(remove).resolves.toBe(notInstalled) + await expect(oldGenerationPoll).resolves.toBe(stale) + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it.each(['conflict', 'not_installed'] as const)( + 'does not mutate a %s registration', + async (state) => { + const current = status(state) + mocks.getStatus.mockResolvedValue(current) + + await expect(installStatusHandler()()).resolves.toBe(current) + expect(mocks.install).not.toHaveBeenCalled() + } + ) + + it('does not mutate a stale non-AppImage registration', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + mocks.resolveAppImageRuntimeIdentity.mockReturnValue(null) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('does not claim a sibling AppImage registration during a status poll', async () => { + const stale = { + ...status('stale'), + currentTarget: '/cache/current/resources/bin/orca-ide' + } + mocks.getStatus.mockResolvedValue(stale) + mocks.isAppImageRegistrationOwnedBySibling.mockReturnValue(true) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('migrates a legacy AppImage target even when a sibling owns the stable endpoint', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockResolvedValue(installed) + mocks.isAppImageRegistrationOwnedBySibling.mockImplementation( + (current: CliInstallStatus) => current.currentTarget === current.launcherPath + ) + + await expect(installStatusHandler()()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it('does not mutate a stale registration off Linux', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index 659c69d9c02..050a00f05cf 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { resolveAppImageCacheKey } from '../cli/appimage-extracted-root' import { CliInstaller } from '../cli/cli-installer' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' import { recordWslCliRegistrationInstalled, recordWslCliRegistrationRemoved @@ -10,6 +12,31 @@ import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registr import { getCanonicalUserDataPath } from '../persistence' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getDefaultWslDistro } from '../wsl' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' + +const APPIMAGE_REPAIR_RETRY_MS = 30_000 +const localCliRegistrationQueues = new Map>() + +function runLocalCliRegistrationOperation(operation: () => Promise): Promise { + return runKeyedSerializedOperation(localCliRegistrationQueues, 'local', operation) +} + +function resolveStaleAppImageRepairKey(status: CliInstallStatus): string | null { + if (status.state !== 'stale') { + return null + } + const runtimeIdentity = resolveAppImageRuntimeIdentity() + if (!runtimeIdentity) { + return null + } + const cacheKey = resolveAppImageCacheKey(runtimeIdentity.appImagePath) + if (!cacheKey) { + return null + } + return [status.commandPath, status.launcherPath, runtimeIdentity.appImagePath, cacheKey].join( + '\0' + ) +} function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined { return args?.distro?.trim() || undefined @@ -57,19 +84,57 @@ async function hydrateLocalShellPathForCli(force = false): Promise { } export function registerCliHandlers(): void { + let staleAppImageRepairAttempt: { + promise: Promise + retryAfter: number + } | null = null + ipcMain.handle('cli:getInstallStatus', async (): Promise => { await hydrateLocalShellPathForCli() - return new CliInstaller().getStatus() + const installer = new CliInstaller() + const status = await installer.getStatus() + // Why: an AppImage update replaces the outer file while the managed symlink still targets the prior extracted payload. + const repairKey = resolveStaleAppImageRepairKey(status) + if (!repairKey || installer.isAppImageRegistrationOwnedBySibling(status)) { + return status + } + + if (!staleAppImageRepairAttempt || Date.now() >= staleAppImageRepairAttempt.retryAfter) { + const promise = runLocalCliRegistrationOperation(async () => { + const currentInstaller = new CliInstaller() + const currentStatus = await currentInstaller.getStatus() + return resolveStaleAppImageRepairKey(currentStatus) === repairKey && + !currentInstaller.isAppImageRegistrationOwnedBySibling(currentStatus) + ? currentInstaller.install() + : currentStatus + }).catch((error) => { + console.warn( + '[cli] Failed to repair stale AppImage registration:', + error instanceof Error ? error.message : String(error) + ) + return null + }) + staleAppImageRepairAttempt = { promise, retryAfter: Number.POSITIVE_INFINITY } + void promise.then((result) => { + if (staleAppImageRepairAttempt?.promise !== promise) { + return + } + staleAppImageRepairAttempt = result + ? null + : { ...staleAppImageRepairAttempt, retryAfter: Date.now() + APPIMAGE_REPAIR_RETRY_MS } + }) + } + return (await staleAppImageRepairAttempt.promise) ?? status }) ipcMain.handle('cli:install', async (): Promise => { await hydrateLocalShellPathForCli(true) - return new CliInstaller().install() + return runLocalCliRegistrationOperation(() => new CliInstaller().install()) }) ipcMain.handle('cli:remove', async (): Promise => { await hydrateLocalShellPathForCli() - return new CliInstaller().remove() + return runLocalCliRegistrationOperation(() => new CliInstaller().remove()) }) ipcMain.handle( diff --git a/src/main/ipc/created-worktree-root-prune.test.ts b/src/main/ipc/created-worktree-root-prune.test.ts index 9114e34a07c..30808d7d79e 100644 --- a/src/main/ipc/created-worktree-root-prune.test.ts +++ b/src/main/ipc/created-worktree-root-prune.test.ts @@ -2,7 +2,7 @@ import type * as NodeFsPromises from 'node:fs/promises' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RepoWorktrees from '../repo-worktrees' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import type { Store } from '../persistence' import type { Repo } from '../../shared/repo-types' import { @@ -22,7 +22,7 @@ vi.mock('node:fs/promises', async () => { vi.mock('../repo-worktrees', async () => { const actual = await vi.importActual('../repo-worktrees') - return { ...actual, listRepoWorktrees: vi.fn() } + return { ...actual, listRepoWorktreeGraph: vi.fn() } }) const repo: Repo = { @@ -56,10 +56,10 @@ describe('recovered worktree root pruning', () => { beforeEach(() => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() - vi.mocked(listRepoWorktrees).mockReset() + vi.mocked(listRepoWorktreeGraph).mockReset() // The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild // reports success with the recovered row missing and the probe is the only remaining evidence. - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) statMock.mockReset() statMock.mockResolvedValue({}) }) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 41c2c44a634..fa82b7a652c 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -29,7 +29,7 @@ vi.mock('../repo-worktrees', async () => { const actual = await vi.importActual('../repo-worktrees') return { ...actual, - listRepoWorktrees: vi.fn() + listRepoWorktreeGraph: vi.fn() } }) @@ -98,7 +98,7 @@ describe('filesystem auth worktree roots', () => { beforeEach(() => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() - vi.mocked(listRepoWorktrees).mockReset() + vi.mocked(listRepoWorktreeGraph).mockReset() }) it('rebuilds the authorized roots cache for large worktree lists', async () => { @@ -112,7 +112,7 @@ describe('filesystem auth worktree roots', () => { isMainWorktree: false }) ) - vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees) const store = makeStore() await rebuildAuthorizedRootsCache(store) @@ -121,7 +121,7 @@ describe('filesystem auth worktree roots', () => { await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe( resolve(lastWorktreePath) ) - expect(listRepoWorktrees).toHaveBeenCalledTimes(1) + expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1) }) it("keeps a repo's roots when its listing fails mid-rebuild", async () => { @@ -129,7 +129,7 @@ describe('filesystem auth worktree roots', () => { // a worktree a create just recovered without a listing (#16520). const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered') - vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.')) + vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.')) await rebuildAuthorizedRootsCache(store) @@ -146,7 +146,7 @@ describe('filesystem auth worktree roots', () => { await mkdir(recovered) const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, recovered) - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) await rebuildAuthorizedRootsCache(store) @@ -160,7 +160,7 @@ describe('filesystem auth worktree roots', () => { await mkdir(recovered) const store = makeStore() // Register mid-listing: the rebuild's own result was computed before this worktree existed. - vi.mocked(listRepoWorktrees).mockImplementation(async () => { + vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => { registerCreatedWorktreeRoot(store, repo.id, recovered) return [] }) @@ -174,7 +174,7 @@ describe('filesystem auth worktree roots', () => { it('retires a recovered root once the listing can see it again', async () => { const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/feature') - vi.mocked(listRepoWorktrees).mockResolvedValue([ + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([ { path: '/linked/feature', head: '', @@ -189,7 +189,7 @@ describe('filesystem auth worktree roots', () => { await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe( resolve('/linked/feature') ) - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) await rebuildAuthorizedRootsCache(store) await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow( @@ -205,7 +205,7 @@ describe('filesystem auth worktree roots', () => { })) let active = 0 let maxActive = 0 - vi.mocked(listRepoWorktrees).mockImplementation(async () => { + vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => { active += 1 maxActive = Math.max(maxActive, active) await new Promise((resolve) => setTimeout(resolve, 1)) @@ -215,7 +215,7 @@ describe('filesystem auth worktree roots', () => { await rebuildAuthorizedRootsCache(makeStore(repos)) - expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length) + expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length) expect(maxActive).toBeLessThanOrEqual(8) }) }) @@ -392,7 +392,7 @@ describe('filesystem-auth path containment', () => { vi.resetModules() vi.doMock('../repo-worktrees', () => ({ isRepoRoot: vi.fn(), - listRepoWorktrees: vi.fn() + listRepoWorktreeGraph: vi.fn() })) vi.doMock('path', async () => { const path = await vi.importActual('node:path') diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 402409defa3..47efa88d6cd 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -107,6 +107,7 @@ export const gitStatusModuleMock = { export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock } export const gitWorktreeMock = { + listWorktreeGraph: listWorktreesMock, listWorktrees: listWorktreesMock, listWorktreesStrict: listWorktreesMock } 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/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 62b86e7361a..2f9e5e92c09 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -5,7 +5,7 @@ import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/comm import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' import { resolve } from 'node:path' import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { listRepoWorktrees } from '../../repo-worktrees' +import { listRepoWorktreeGraph } from '../../repo-worktrees' import { resolveAuthorizedPath } from '../filesystem-auth' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { splitWorktreeId } from '../../../shared/worktree/id' @@ -77,7 +77,7 @@ async function localRepoOwnsWorktree( return true } try { - const worktrees = await listRepoWorktrees(repo) + const worktrees = await listRepoWorktreeGraph(repo) return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) } catch { return false 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/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 7cad33211d6..3f273490d7b 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -17,7 +17,7 @@ const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, resolveRegisteredWorktreePathMock, - listRepoWorktreesMock + listRepoWorktreeGraphMock } = vi.hoisted(() => ({ handleMock: vi.fn(), createHostedReviewMock: vi.fn(), @@ -25,7 +25,7 @@ const { getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), resolveRegisteredWorktreePathMock: vi.fn(), - listRepoWorktreesMock: vi.fn() + listRepoWorktreeGraphMock: vi.fn() })) vi.mock('electron', () => ({ @@ -52,7 +52,7 @@ vi.mock('./registered-worktree-roots-cache', () => ({ })) vi.mock('../repo-worktrees', () => ({ - listRepoWorktrees: listRepoWorktreesMock + listRepoWorktreeGraph: listRepoWorktreeGraphMock })) import { registerHostedReviewHandlers } from './hosted-review' @@ -97,7 +97,7 @@ describe('registerHostedReviewHandlers', () => { getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewForBranchMock.mockReset() resolveRegisteredWorktreePathMock.mockReset() - listRepoWorktreesMock.mockReset() + listRepoWorktreeGraphMock.mockReset() store.getRepo.mockReset() store.getRepos.mockReset() store.getProjects.mockReset() @@ -114,7 +114,7 @@ describe('registerHostedReviewHandlers', () => { store.getRepos.mockReturnValue([repo]) store.getProjects.mockReturnValue([]) store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) - listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: worktreePath }]) }) it('routes local WSL project review creation through main-process runtime options', async () => { @@ -143,7 +143,7 @@ describe('registerHostedReviewHandlers', () => { ]) const resolvedWorktreePath = resolve('/workspace/feature') resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) - listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }]) createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, @@ -162,7 +162,7 @@ describe('registerHostedReviewHandlers', () => { title: 'Feature PR' }) - expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) + expect(listRepoWorktreeGraphMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) expect(createHostedReviewMock).toHaveBeenCalledWith( resolvedWorktreePath, expect.objectContaining({ @@ -193,7 +193,7 @@ describe('registerHostedReviewHandlers', () => { store.getRepos.mockReturnValue([localRepo]) const resolvedWorktreePath = resolve('/workspace/feature') resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) - listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }]) createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' }) registerHostedReviewHandlers(store as never, stats as never) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index c431459f4f7..f64aeb8aa4d 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -16,7 +16,7 @@ import { import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { getRepoExecutionHostId } from '../../shared/execution-host' @@ -68,7 +68,7 @@ async function resolveHostedReviewWorktreePath( } if (repo.connectionId) { const remoteWorktreePath = normalizeRemoteHostedReviewPath(worktreePath) - const repoWorktrees = await listRepoWorktrees(repo) + const repoWorktrees = await listRepoWorktreeGraph(repo) if ( !repoWorktrees.some( (worktree) => normalizeRemoteHostedReviewPath(worktree.path) === remoteWorktreePath @@ -82,8 +82,8 @@ async function resolveHostedReviewWorktreePath( const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) const repoWorktrees = Object.keys(localGitOptions).length > 0 - ? await listRepoWorktrees(repo, localGitOptions) - : await listRepoWorktrees(repo) + ? await listRepoWorktreeGraph(repo, localGitOptions) + : await listRepoWorktreeGraph(repo) if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { throw new Error('Access denied: worktree does not belong to repository') } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index bf3ec10dc27..b411febfa5d 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -1,4 +1,3 @@ -import { join } from 'node:path' import { vi } from 'vitest' import type { Mock } from 'vitest' import type * as Wsl from '../wsl' @@ -17,6 +16,7 @@ export const mkdirSyncMock: Mock = vi.fn() export const readFileSyncMock: Mock = vi.fn() export const writeFileSyncMock: Mock = vi.fn() export const chmodSyncMock: Mock = vi.fn() +export const linuxCliShimMock: Mock = vi.fn() export const renameSyncMock: Mock = vi.fn() export const rmSyncMock: Mock = vi.fn() export const getPathMock: Mock = vi.fn() @@ -152,8 +152,7 @@ export const classifyErrorModuleMock = () => ({ // Why: the real ensure writes to process.resourcesPath (absent under vitest); env assembly only needs the returned dir path. export const linuxCliShimModuleMock = () => ({ - ensureLinuxTerminalOrcaCliShimDir: (options: { userDataPath: string }) => - join(options.userDataPath, 'linux-orca-cli-shim') + ensureLinuxTerminalOrcaCliShimDir: linuxCliShimMock }) export const ptyRegistryModuleMock = () => ({ diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 95c7dfe3eea..d372ecb734b 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, vi } from 'vitest' import * as electron from 'electron' +import { join } from 'node:path' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { setPtyHostBindings } from './pty-host-bindings' import { testPtyIpcSurface } from './pty-ipc-test-surface' @@ -16,6 +17,7 @@ import { readFileSyncMock, writeFileSyncMock, chmodSyncMock, + linuxCliShimMock, getPathMock, loginPreflightExecFileMock, spawnMock, @@ -139,6 +141,10 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { readFileSyncMock.mockReset() writeFileSyncMock.mockReset() chmodSyncMock.mockReset() + linuxCliShimMock.mockReset() + linuxCliShimMock.mockImplementation((options: { userDataPath: string }) => + join(options.userDataPath, 'linux-orca-cli-shim') + ) getPathMock.mockReset() loginPreflightExecFileMock.mockReset() spawnMock.mockReset() diff --git a/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts b/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts new file mode 100644 index 00000000000..d42103dc518 --- /dev/null +++ b/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { linuxCliShimMock } from './pty-ipc-mock-registry' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +import { registerPtyHandlers } from './pty' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) +}) + +describe('restored AppImage CLI shim refresh', () => { + const { mainWindow } = setupPtyIpcSuite() + + it('refreshes the live launcher before any restored pane reattaches', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + registerPtyHandlers(mainWindow as never) + + expect(linuxCliShimMock).toHaveBeenCalledOnce() + expect(linuxCliShimMock).toHaveBeenCalledWith({ userDataPath: '/tmp/orca-user-data' }) + }) + + it('does not publish a host launcher on a non-Linux host', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + registerPtyHandlers(mainWindow as never) + + expect(linuxCliShimMock).not.toHaveBeenCalled() + }) +}) 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/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9c46c942383..9a6230ad82b 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -1,4 +1,5 @@ import type { BrowserWindow } from 'electron' +import { getAppEnvironment } from '../../../shared/app-environment' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -58,6 +59,7 @@ import { resolveCodexResumeLaunch, stripSequencedStartupResumeArgv } from './host-env/codex-resume' +import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim' export function registerPtyHandlers( mainWindow: BrowserWindow, @@ -68,6 +70,12 @@ export function registerPtyHandlers( store?: Store, options?: PtyIpcSessionOptions ): void { + if (process.platform === 'linux') { + const appEnvironment = getAppEnvironment() + if (appEnvironment.isPackaged()) { + ensureLinuxTerminalOrcaCliShimDir({ userDataPath: appEnvironment.getPath('userData') }) + } + } const ipcMain = getPtyIpc() // Why first: the outgoing session owns the producer pauses, so its real reset must run // before the bridge is neutralized or a PTY paused during re-registration stays paused. diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index 9e1d20733df..ad8c40535cf 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' import { withTimeout } from '../../shared/promise-timeout-fallback' import { getErrorCode } from '../git/worktree-operation-options' import type { Store } from '../persistence' -import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' +import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees' import { getLocalRepos } from './filesystem-allowed-roots' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' @@ -54,7 +54,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { try { roots.push(resolve(repo.path)) - for (const worktree of await listRepoWorktrees(repo)) { + for (const worktree of await listRepoWorktreeGraph(repo)) { roots.push(resolve(worktree.path)) } } catch (error) { 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 86e2dc5ee10..cc9081ce2a2 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -61,8 +61,11 @@ vi.mock('fs/promises', () => ({ rm: rmMock })) +// `availableParallelism` is read at module load by the git admission scheduler, +// which this module graph reaches; a partial `os` mock breaks that import. vi.mock('os', () => ({ - homedir: homedirMock + homedir: homedirMock, + availableParallelism: () => 8 })) vi.mock('../git/runner', () => ({ @@ -148,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/telemetry.ts b/src/main/ipc/telemetry.ts index c1fe7d0f68d..5e649f51b84 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -24,7 +24,9 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'daemon_adopted', 'daemon_audit_eligibility', + 'daemon_pty_cwd_denied', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/main/ipc/worktree-base-directory-marker-poller.ts b/src/main/ipc/worktree-base-directory-marker-poller.ts new file mode 100644 index 00000000000..dba1c4df03c --- /dev/null +++ b/src/main/ipc/worktree-base-directory-marker-poller.ts @@ -0,0 +1,289 @@ +import { readdir, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import type { + WorktreeBasePollerOptions, + WorktreeBasePollEvent, + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: the mtime gate is an optimization, not a correctness boundary — some +// filesystems have coarse dir timestamps, and pending `.git` markers expire. +// A periodic ungated scan guarantees eventual convergence. +export const WORKTREE_BASE_BACKSTOP_TICKS = 15 + +// Why: a `.git` completion marker lands within moments of its worktree dir +// (git writes it before populating the checkout). Dirs that never get one are +// not worktrees; stop re-statting them after this many ticks and let the +// backstop scan cover the pathological case. +const PENDING_MARKER_MAX_TICKS = 300 + +// Why: matches the git-common poller's fan-out bound (#17828) — bounded +// concurrency turns hundreds of serial round trips into a handful of batches +// without dumping every candidate onto libuv's 4-thread pool at once. +const MARKER_PROBE_CONCURRENCY = 8 + +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +async function hasGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +type BaseSnapshot = { + // worktree-candidate dir → whether its `.git` completion marker exists + markers: Map + // dirs whose listing determines the candidate set: the root plus any + // nested repo containers. Their stat signatures gate the next full scan. + gateDirs: string[] + // index-aligned with gateDirs, each sampled *before* that dir's listing + gateSignatures: string[] +} + +async function readdirSafe(path: string): Promise { + try { + return await readdir(path, { withFileTypes: true }) + } catch { + return [] + } +} + +// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested +// repo's container, mirroring what worktree-base-directory-event-filter +// matches: `/.git` completion markers and `` deletions. +async function snapshotBase( + rootPath: string, + repos: ReadonlyMap +): Promise { + const markers = new Map() + const gateDirs = [rootPath] + // Why: sampling the signature before the listing makes a write that races the + // scan look stale next tick (one redundant rescan) instead of invisible until + // the backstop, which is up to 15 ticks of missed creates/deletes. + const gateSignatures = [await dirSignature(rootPath)] + const configs = [...repos.values()] + const includeFlat = configs.some((config) => !config.nestWorkspaces) + const nestedRepoNames = new Set( + configs + .filter((config) => config.nestWorkspaces) + .map((config) => normalizeRuntimePathForComparison(config.repoName)) + ) + + // Root vanished or unreadable: readdirSafe yields [], producing the same + // empty markers/candidates result as the old watcher's error path. + const rootEntries = await readdirSafe(rootPath) + + const candidates: string[] = [] + for (const entry of rootEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + candidates.push(entryPath) + } + if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + gateDirs.push(entryPath) + gateSignatures.push(await dirSignature(entryPath)) + const subEntries = await readdirSafe(entryPath) + for (const sub of subEntries) { + if (sub.isDirectory() || sub.isSymbolicLink()) { + candidates.push(join(entryPath, sub.name)) + } + } + } + } + + await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { + markers.set(dir, await hasGitMarker(dir)) + }) + return { markers, gateDirs, gateSignatures } +} + +function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, marker] of next.markers) { + if (marker && prev.markers.get(dir) !== true) { + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + for (const dir of prev.markers.keys()) { + if (!next.markers.has(dir)) { + events.push({ type: 'delete', path: dir }) + } + } + return events +} + +export async function startBasePoller( + target: WorktreeBaseWatchTarget, + getRepos: () => ReadonlyMap, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, + options: WorktreeBasePollerOptions +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotBase(target.path, getRepos()) + let timer: ReturnType | null = null + let parkedWhileHidden = false + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() + for (const [dir, marker] of snapshot.markers) { + if (!marker) { + markerProbeStartedAt.set(dir, 0) + } + } + + const fullScan = async (): Promise => { + options.onFullScan?.() + const next = await snapshotBase(target.path, getRepos()) + await options.onSnapshotTaken?.(tickCount) + if (disposed) { + return + } + const events = diffBase(snapshot, next) + for (const [dir, marker] of next.markers) { + if (marker) { + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) + } + } + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) + } + } + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } + + const checkPendingMarkers = async (): Promise => { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) + if (await hasGitMarker(dir)) { + markerProbeStartedAt.delete(dir) + snapshot.markers.set(dir, true) + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + if (!disposed && events.length > 0) { + onEvents(events) + } + } + + const poll = async (forceFullScan = false): Promise => { + tickCount++ + if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + await fullScan() + return + } + // Idle fast path: when the dirs whose listings define the candidate set + // are untouched, skip the readdir + per-candidate stat fan-out entirely. + const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + const gateChanged = + signatures.length !== snapshot.gateSignatures.length || + signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) + if (gateChanged) { + await fullScan() + return + } + if (markerProbeStartedAt.size > 0) { + await checkPendingMarkers() + } + } + + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { + return + } + ticking = true + // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not + // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. + const startedAt = Date.now() + try { + await poll(forceFullScan) + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the ordinary dir-signature gate can miss same-granule changes made + // while hidden; resume must diff a fresh full snapshot against the baseline. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() + } + } +} diff --git a/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts new file mode 100644 index 00000000000..023a8390ccd --- /dev/null +++ b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +// Why: the backstop full scan stats a `.git` marker per candidate dir; an +// unbounded fan-out at hundreds of worktrees would queue thousands of `stat` +// calls on libuv's 4-thread pool (#17828). +const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + try { + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +function makeTarget(path: string): WorktreeBaseWatchTarget { + const repoConfig: WorktreeBaseRepoWatchConfig = { + repoId: 'repo-1', + repoName: 'project', + nestWorkspaces: false + } + return { + key: `base:local:${path}`, + kind: 'base', + path, + repos: new Map([[repoConfig.repoId, repoConfig]]) + } +} + +describe('worktree base directory poller marker fan-out (#17828)', () => { + const cleanups: (() => Promise)[] = [] + + beforeEach(() => { + concurrency.current = 0 + concurrency.peak = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const candidateCount = 200 + for (let i = 0; i < candidateCount; i++) { + const worktree = join(root, `wt-${i}`) + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + } + + const target = makeTarget(root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { pollIntervalMs: 100_000 } + ) + cleanups.push(() => poller.unsubscribe()) + + // 200 candidates stated unbounded would peak near 200 concurrent `stat` + // calls; bounding the marker probe keeps the peak independent of count — + // while still overlapping requests (not serialized one-at-a-time). + expect(concurrency.peak).toBeGreaterThan(1) + expect(concurrency.peak).toBeLessThan(20) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 42ee184b811..39c5b5f48c0 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,13 +1,13 @@ -import { readdir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { startBasePoller } from './worktree-base-directory-marker-poller' import { startGitCommonWatch } from './worktree-git-common-watch' +export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller' + export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } export type WorktreeBaseSubscription = { unsubscribe: () => Promise } @@ -61,6 +61,8 @@ export type WorktreeBasePollerOptions = { visibility?: WorktreePollerWindowVisibility getGitStatusRefPaths?: () => readonly string[] onWatchError?: (error: Error) => void + /** Called when the watcher child dropped an event batch (git-common narrow watch only). */ + onOverflow?: () => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void /** Test hook: called before a pending `.git` marker stat. */ @@ -81,277 +83,6 @@ export type WorktreeBasePollerOptions = { // Orca's own worktree operations notify the renderer directly. export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000 -// Why: the mtime gate is an optimization, not a correctness boundary — some -// filesystems have coarse dir timestamps, and pending `.git` markers expire. -// A periodic ungated scan guarantees eventual convergence. -export const WORKTREE_BASE_BACKSTOP_TICKS = 15 - -// Why: a `.git` completion marker lands within moments of its worktree dir -// (git writes it before populating the checkout). Dirs that never get one are -// not worktrees; stop re-statting them after this many ticks and let the -// backstop scan cover the pathological case. -const PENDING_MARKER_MAX_TICKS = 300 - -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - return statSignature(await stat(path)) - } catch { - return 'missing' - } -} - -async function hasGitMarker(dir: string): Promise { - try { - await stat(join(dir, '.git')) - return true - } catch { - return false - } -} - -type BaseSnapshot = { - // worktree-candidate dir → whether its `.git` completion marker exists - markers: Map - // dirs whose listing determines the candidate set: the root plus any - // nested repo containers. Their stat signatures gate the next full scan. - gateDirs: string[] - // index-aligned with gateDirs, each sampled *before* that dir's listing - gateSignatures: string[] -} - -// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested -// repo's container, mirroring what worktree-base-directory-event-filter -// matches: `/.git` completion markers and `` deletions. -async function snapshotBase( - rootPath: string, - repos: ReadonlyMap -): Promise { - const markers = new Map() - const gateDirs = [rootPath] - // Why: sampling the signature before the listing makes a write that races the - // scan look stale next tick (one redundant rescan) instead of invisible until - // the backstop, which is up to 15 ticks of missed creates/deletes. - const gateSignatures = [await dirSignature(rootPath)] - const configs = [...repos.values()] - const includeFlat = configs.some((config) => !config.nestWorkspaces) - const nestedRepoNames = new Set( - configs - .filter((config) => config.nestWorkspaces) - .map((config) => normalizeRuntimePathForComparison(config.repoName)) - ) - - let rootEntries - try { - rootEntries = await readdir(rootPath, { withFileTypes: true }) - } catch { - // Root vanished: an empty snapshot diffs into delete events for every - // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs, gateSignatures } - } - - const candidates: string[] = [] - for (const entry of rootEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - const entryPath = join(rootPath, entry.name) - if (includeFlat) { - candidates.push(entryPath) - } - if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { - gateDirs.push(entryPath) - gateSignatures.push(await dirSignature(entryPath)) - let subEntries - try { - subEntries = await readdir(entryPath, { withFileTypes: true }) - } catch { - subEntries = [] - } - for (const sub of subEntries) { - if (sub.isDirectory() || sub.isSymbolicLink()) { - candidates.push(join(entryPath, sub.name)) - } - } - } - } - - for (const dir of candidates) { - markers.set(dir, await hasGitMarker(dir)) - } - return { markers, gateDirs, gateSignatures } -} - -function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, marker] of next.markers) { - if (marker && prev.markers.get(dir) !== true) { - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - for (const dir of prev.markers.keys()) { - if (!next.markers.has(dir)) { - events.push({ type: 'delete', path: dir }) - } - } - return events -} - -async function startBasePoller( - target: WorktreeBaseWatchTarget, - getRepos: () => ReadonlyMap, - onEvents: (events: WorktreeBasePollEvent[]) => void, - pollIntervalMs: number, - visibility: WorktreePollerWindowVisibility, - options: WorktreeBasePollerOptions -): Promise { - let disposed = false - let ticking = false - let tickCount = 0 - let snapshot = await snapshotBase(target.path, getRepos()) - let timer: ReturnType | null = null - let parkedWhileHidden = false - const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS - // dir → first probe tick; null means backstop scans only - const markerProbeStartedAt = new Map() - for (const [dir, marker] of snapshot.markers) { - if (!marker) { - markerProbeStartedAt.set(dir, 0) - } - } - - const fullScan = async (): Promise => { - options.onFullScan?.() - const next = await snapshotBase(target.path, getRepos()) - await options.onSnapshotTaken?.(tickCount) - if (disposed) { - return - } - const events = diffBase(snapshot, next) - for (const [dir, marker] of next.markers) { - if (marker) { - markerProbeStartedAt.delete(dir) - } else if (!markerProbeStartedAt.has(dir)) { - markerProbeStartedAt.set(dir, tickCount) - } - } - for (const dir of markerProbeStartedAt.keys()) { - if (!next.markers.has(dir)) { - markerProbeStartedAt.delete(dir) - } - } - snapshot = next - if (events.length > 0) { - onEvents(events) - } - } - - const checkPendingMarkers = async (): Promise => { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, firstSeenTick] of markerProbeStartedAt) { - if (firstSeenTick === null) { - continue - } - if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { - markerProbeStartedAt.set(dir, null) - continue - } - options.onPendingMarkerProbe?.(join(dir, '.git')) - if (await hasGitMarker(dir)) { - markerProbeStartedAt.delete(dir) - snapshot.markers.set(dir, true) - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - if (!disposed && events.length > 0) { - onEvents(events) - } - } - - const poll = async (forceFullScan = false): Promise => { - tickCount++ - if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { - await fullScan() - return - } - // Idle fast path: when the dirs whose listings define the candidate set - // are untouched, skip the readdir + per-candidate stat fan-out entirely. - const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) - const gateChanged = - signatures.length !== snapshot.gateSignatures.length || - signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) - if (gateChanged) { - await fullScan() - return - } - if (markerProbeStartedAt.size > 0) { - await checkPendingMarkers() - } - } - - const tick = async (forceFullScan = false): Promise => { - timer = null - if (disposed) { - return - } - if (!visibility.isWindowVisible()) { - parkedWhileHidden = true - return - } - if (ticking) { - return - } - ticking = true - // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not - // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. - const startedAt = Date.now() - try { - await poll(forceFullScan) - } catch { - // Transient fs error: keep the previous snapshot and retry next tick. - } finally { - ticking = false - } - if (!disposed) { - // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would - // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for - // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. - const nextDelay = Math.max( - 0, - Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) - ) - timer = setTimeout(() => void tick(), nextDelay) - timer.unref?.() - } - } - - const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { - if (disposed || !parkedWhileHidden) { - return - } - parkedWhileHidden = false - // Why: the ordinary dir-signature gate can miss same-granule changes made - // while hidden; resume must diff a fresh full snapshot against the baseline. - void tick(true) - }) - - timer = setTimeout(() => void tick(), pollIntervalMs) - timer.unref?.() - - return { - unsubscribe: async () => { - disposed = true - if (timer) { - clearTimeout(timer) - } - unsubscribeVisibility() - } - } -} - /** Watches the shallow paths a worktree base target cares about and emits * watcher-shaped events. Resolves once the baseline (snapshot or narrow * native subscription) is established. */ @@ -373,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller( visibility, options.onFullScan, options.getGitStatusRefPaths, - options.onWatchError + options.onWatchError, + options.onOverflow ) } return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) diff --git a/src/main/ipc/worktree-base-directory-watch-events.ts b/src/main/ipc/worktree-base-directory-watch-events.ts new file mode 100644 index 00000000000..caed5627b2b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-events.ts @@ -0,0 +1,90 @@ +import { + collectLocalWorktreeBaseChanges, + collectRemoteWorktreeBaseChanges, + hasCollectedWorktreeBaseChanges +} from './worktree-base-directory-change-collector' +import { + scheduleWorktreeBaseNotification, + type WorktreeBaseNotificationWatch +} from './worktree-base-directory-notifications' +import { + invalidateActiveGitStatusRefResolution, + invalidateGitStatusRefResolutionForPaths +} from './worktree-git-status-ref-watch' +import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' + +export type ActiveWatch = WorktreeBaseNotificationWatch & { + subscription: { unsubscribe: () => Promise } + gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown +} + +export function handleLocalWatchEvents( + watch: ActiveWatch, + error: Error | null, + events: { type: 'create' | 'update' | 'delete'; path: string }[], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + if (error) { + console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } + return + } + watch.watcherFailureRefresh.reset() + invalidateGitStatusRefResolutionForPaths( + watch, + events.map((event) => event.path), + getActiveWatches + ) + const changes = collectLocalWorktreeBaseChanges(watch, events) + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} + +// Why: after a dropped event batch nothing about the prior state can be +// trusted — widen unconditionally (structural + status + head-identity), +// same shape as the remote overflow branch below, bypassing the watcher-error +// cooldown so a burst of overflows during one bulk op cannot suppress the +// refresh the fleet actually needs. +export function handleWatchOverflow( + watch: ActiveWatch, + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) +} + +export function handleRemoteWatchEvents( + watch: ActiveWatch, + events: Parameters[1], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateGitStatusRefResolutionForPaths( + watch, + events.flatMap((event) => + event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] + ), + getActiveWatches + ) + const changes = collectRemoteWorktreeBaseChanges(watch, events) + if (changes.overflow) { + handleWatchOverflow(watch, getActiveWatches) + return + } + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 5d701b91211..a23bc901bdc 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledOnce() }) + it('widens an overflowed local git-common watch to a structural refresh', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + const request = { + worktreeId: `repo-1::${PROJECT_ROOT}`, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await setWorktreeGitStatusRefWatch(request, resolve) + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + // Overflow is definite proof of loss, not a possibly-transient error — it + // invalidates the cached ref resolution unconditionally. + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledTimes(2) + }) + + it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + // A watcher-error burst within the 60s cooldown window collapses to one + // refresh (see "throttles repeated structural refreshes from watcher + // failures" above); overflow must not inherit that gate, since a bulk op + // can legitimately overflow more than once before it settles. + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 57a231e89b2..abb0d51782c 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -6,16 +6,9 @@ import { disposeWorktreeHeadIdentityRefreshState, refreshWorktreeHeadIdentities } from './worktree-head-identity-refresh' -import { - collectLocalWorktreeBaseChanges, - collectRemoteWorktreeBaseChanges, - hasCollectedWorktreeBaseChanges -} from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, - scheduleWorktreeBaseNotification, - supportsWorktreeHeadIdentityRefresh, - type WorktreeBaseNotificationWatch + supportsWorktreeHeadIdentityRefresh } from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope' @@ -30,18 +23,16 @@ import { import { applyActiveGitStatusRefBinding, clearActiveGitStatusRefBinding, - invalidateActiveGitStatusRefResolution, - invalidateGitStatusRefResolutionForPaths, updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' - -type ActiveWatch = WorktreeBaseNotificationWatch & { - subscription: { unsubscribe: () => Promise } - gitStatusRefPaths: Set - watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown -} +import { + handleLocalWatchEvents, + handleRemoteWatchEvents, + handleWatchOverflow, + type ActiveWatch +} from './worktree-base-directory-watch-events' const activeWatches = new Map() let syncGeneration = 0 @@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function handleLocalWatchEvents( - watch: ActiveWatch, - error: Error | null, - events: { type: 'create' | 'update' | 'delete'; path: string }[] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - if (error) { - console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - if (watch.watcherFailureRefresh.consume()) { - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - } - return - } - watch.watcherFailureRefresh.reset() - invalidateGitStatusRefResolutionForPaths( - watch, - events.map((event) => event.path), - () => activeWatches.values() - ) - const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - -function handleRemoteWatchEvents( - watch: ActiveWatch, - events: Parameters[1] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - invalidateGitStatusRefResolutionForPaths( - watch, - events.flatMap((event) => - event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] - ), - () => activeWatches.values() - ) - const changes = collectRemoteWorktreeBaseChanges(watch, events) - if (changes.overflow) { - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - return - } - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - function createActiveWatch( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow, @@ -146,7 +84,7 @@ async function subscribeTarget( if (!currentWatch || currentWatch.disposed) { return } - handleRemoteWatchEvents(currentWatch, events) + handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values()) }) activeWatch = createActiveWatch( target, @@ -167,7 +105,7 @@ async function subscribeTarget( (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, null, events) + handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values()) } }, { @@ -178,7 +116,13 @@ async function subscribeTarget( onWatchError: (error) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, error, []) + handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values()) + } + }, + onOverflow: () => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (currentWatch) { + handleWatchOverflow(currentWatch, () => activeWatches.values()) } } } diff --git a/src/main/ipc/worktree-git-common-entry-snapshot.ts b/src/main/ipc/worktree-git-common-entry-snapshot.ts index d14f4ec8a1d..6dad6e0a048 100644 --- a/src/main/ipc/worktree-git-common-entry-snapshot.ts +++ b/src/main/ipc/worktree-git-common-entry-snapshot.ts @@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry( previous: GitCommonEntrySnapshot | undefined, forceFullScan: boolean ): Promise { - // Structural leaves change in place every tick; only index uses the entry-dir gate. + // Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the + // entry dir, so the entry dir's own signature moves on every one of those writes + // (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash, + // worktree lock/unlock, config --worktree, index writes all move it). The one + // in-place exception is `gitdir` (worktree move/repair), which the periodic + // forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this + // gate. Gating all of these leaves on the entry-dir signature turns an unchanged + // entry into a single stat per tick instead of stat-ing every leaf every tick. + const nextDirSignature = await gitCommonDirectorySignature(entryPath) + if (nextDirSignature === 'missing') { + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures: new Map(), + indexSignature: null, + headLogSignature: null + } + ) + } + const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + if (!shouldRescan) { + return previous + } const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - gitCommonDirectorySignature(entryPath), + const [headLogSignature, indexSignature] = await Promise.all([ gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)), + gitCommonFileSignature(join(entryPath, INDEX_FILE)), Promise.all( STRUCTURAL_METADATA_FILES.map(async (name) => { const signature = await gitCommonFileSignature(join(entryPath, name)) @@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry( }) ) ]) - if (nextDirSignature === 'missing') { - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature - } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await gitCommonFileSignature(join(entryPath, INDEX_FILE)) - : previous.indexSignature return { dirSignature: nextDirSignature, structuralSignatures, diff --git a/src/main/ipc/worktree-git-common-narrow-watch.ts b/src/main/ipc/worktree-git-common-narrow-watch.ts index b60ac9877e8..fa7c402c5ec 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch( platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + // Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a + // harder loss signal than a transient error — nothing about the prior state + // can be trusted, so this bypasses onWatchError's failure cooldown instead + // of reusing it. + onOverflow?: () => void ): Promise { const worktreesDir = join(target.path, 'worktrees') const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {} @@ -73,6 +78,11 @@ export async function startGitCommonNarrowWatch( .unsubscribe() .catch(() => {}) .then(() => + // Crash fuse tripped: this poller is now the sole change signal until a + // future existence-poll upgrade (follow-up: #17878). Its own per-entry + // dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps + // an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence + // stays cheap at high worktree counts without needing to stretch itself. startGitCommonPolling( target.path, onEvents, @@ -222,6 +232,23 @@ export async function startGitCommonNarrowWatch( onEvents([{ type: 'update', path: worktreesDir }]) } } + }, + // Why: the watcher child drops the whole batch past 5,000 events + // (native FSEvents overflow maps to the same op) instead of reporting + // which paths changed. Unlike a transient error, this is definite + // proof of loss, so it always widens rather than falling back to the + // failure-cooldown-gated onWatchError path. + onOverflow: () => { + if (disposed || !active || generation !== nativeSubscriptionGeneration) { + return + } + if (onOverflow) { + onOverflow() + } else if (onWatchError) { + onWatchError(new Error('Git common watcher overflowed')) + } else { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } ) diff --git a/src/main/ipc/worktree-git-common-polling.test.ts b/src/main/ipc/worktree-git-common-polling.test.ts new file mode 100644 index 00000000000..179b73e2c33 --- /dev/null +++ b/src/main/ipc/worktree-git-common-polling.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { startGitCommonPolling } from './worktree-git-common-polling' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: measure the fan-out this poller issues per scan (peak concurrent `stat` +// calls, `readdir` call count as a proxy for "a tick ran") without depending on +// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a +// specific pre-existing entry (its dir plus every leaf), used to prove the +// entry-dir signature gate keeps an unchanged entry to one stat per tick. +const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({ + statDelayMs: { current: 0 }, + readdirCalls: { count: 0 }, + concurrency: { current: 0, peak: 0 }, + entryZeroStatCalls: { count: 0 } +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdir: (...args: Parameters) => { + readdirCalls.count += 1 + return actual.readdir(...args) + }, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + const path = args[0] + const entryZeroSegment = `${sep}wt-0` + if ( + typeof path === 'string' && + (path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`)) + ) { + entryZeroStatCalls.count += 1 + } + try { + if (statDelayMs.current > 0) { + await new Promise((resolve) => setTimeout(resolve, statDelayMs.current)) + } + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +async function makeCommonDir(entryCount: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-')) + for (let i = 0; i < entryCount; i++) { + const entryPath = join(root, 'worktrees', `wt-${i}`) + await mkdir(join(entryPath, 'logs'), { recursive: true }) + await Promise.all([ + writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'), + writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`), + writeFile(join(entryPath, 'index'), Buffer.from([0])), + writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n') + ]) + } + return root +} + +describe('startGitCommonPolling fan-out bounds (#17828)', () => { + const cleanups: (() => Promise)[] = [] + const dirsToRemove: string[] = [] + + beforeEach(() => { + statDelayMs.current = 0 + readdirCalls.count = 0 + concurrency.current = 0 + concurrency.peak = 0 + entryZeroStatCalls.count = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + await Promise.all( + dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) + vi.useRealTimers() + }) + + it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => { + const commonDir = await makeCommonDir(200) + dirsToRemove.push(commonDir) + const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + // 200 entries x ~6 concurrent structural stats each would peak near 1,200 + // unbounded; bounding to 8 in-flight entries keeps the peak independent of + // entry count instead of scaling with it. + expect(concurrency.peak).toBeLessThan(80) + }) + + it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => { + const commonDir = await makeCommonDir(10) + dirsToRemove.push(commonDir) + statDelayMs.current = 20 + const pollIntervalMs = 5 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + readdirCalls.count = 0 + // ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms; + // the ticking guard must serialize scans, not launch overlapping ones. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(readdirCalls.count).toBeLessThan(10) + }) + + it('costs exactly one stat per tick for an unchanged entry', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const pollIntervalMs = 20 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + + // Let the bootstrap snapshot (which always fully reads every entry once) settle. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + readdirCalls.count = 0 + entryZeroStatCalls.count = 0 + await vi.waitFor( + () => { + expect(readdirCalls.count).toBeGreaterThanOrEqual(5) + }, + { timeout: 2_000 } + ) + // Without the entry-dir signature gate, an unchanged entry still costs ~6 + // stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index). + // With the gate, only the entry dir itself is stat'd once nothing changed — + // one stat per tick, in lockstep with the readdir tripwire. + expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1) + expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1) + }) + + it('detects a HEAD rewrite via lock+rename on the next tick', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 20 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const headPath = join(entryDir, 'HEAD') + const headLockPath = join(entryDir, 'HEAD.lock') + // Every real git ref write goes through a lock file + rename inside the entry + // dir (never an in-place overwrite), which moves the entry dir's own signature. + await writeFile(headLockPath, 'ref: refs/heads/feature\n') + await rename(headLockPath, headPath) + + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: headPath }) + }, + { timeout: pollIntervalMs * 10 } + ) + }) + + it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 10 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const gitdirPath = join(entryDir, 'gitdir') + // `gitdir` is the one structural leaf git rewrites in place (worktree move/repair), + // so the entry dir's own signature never moves — the periodic ungated backstop + // (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it. + await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`) + + // Not caught by the next several ticks: the gate stays closed since nothing + // moved the entry dir's own signature. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5)) + expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath }) + + // Eventually caught regardless of the gate, once tick 15 forces the periodic backstop. + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath }) + }, + { timeout: pollIntervalMs * 40 } + ) + }) + + it('still detects entry add/remove correctly with bounded concurrency', async () => { + const commonDir = await makeCommonDir(5) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + 20, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + + const newEntry = join(commonDir, 'worktrees', 'wt-new') + await mkdir(join(newEntry, 'logs'), { recursive: true }) + await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n') + + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'create', path: newEntry }) + }) + + await rm(newEntry, { recursive: true }) + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry }) + }) + }) +}) diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 0418f4a90fd..4b43835a81f 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,5 +1,6 @@ import { readdir } from 'node:fs/promises' import { join } from 'node:path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files' import { diffGitCommon, @@ -23,18 +24,26 @@ import { // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 +// Why: an unbounded fan-out across every worktree admin entry queues thousands +// of ops on libuv's 4-thread default pool, starving every other main-process +// fs call for the scan's duration (#17828). 8 mirrors the existing +// head-identity/exact-ref-probe pools — enough to saturate typical local +// disks without monopolizing the pool. Since snapshotGitCommonEntry's own +// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks +// down to 1 stat per unchanged entry, real in-flight is now bounded by this +// limit rather than limit × per-entry stat count. +const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8 + async function snapshotStatusRefSignatures( paths: ReadonlySet ): Promise> { const signatures = new Map() - await Promise.all( - [...paths].map(async (path) => { - const signature = await gitCommonFileSignature(path) - if (signature !== null) { - signatures.set(path, signature) - } - }) - ) + await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => { + const signature = await gitCommonFileSignature(path) + if (signature !== null) { + signatures.set(path, signature) + } + }) return signatures } @@ -91,12 +100,10 @@ async function snapshotGitCommon( } const entries = new Map() - await Promise.all( - entryPaths.map(async (entryPath) => { - const previousEntry = previous?.entries.get(entryPath) - entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) - }) - ) + await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => { + const previousEntry = previous?.entries.get(entryPath) + entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) + }) // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — // rather than the always-run worktrees-dir readdir. diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 619133263de..bad700b5445 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -526,6 +526,45 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() }) + it('routes a dropped event batch through the dedicated overflow callback', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const onOverflow = vi.fn() + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + alwaysVisible, + undefined, + () => [], + undefined, + onOverflow + ) + cleanups.push(() => watch.unsubscribe()) + + narrowSubscription().hooks.onOverflow?.() + + expect(onOverflow).toHaveBeenCalledOnce() + // The dedicated callback owns the refresh; the generic event/error paths + // must not also fire so the caller cannot double-count the same loss. + expect(received).toEqual([]) + expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() + }) + + it('falls back to a structural change when no overflow callback is wired', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + narrowSubscription().hooks.onOverflow?.() + + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + }) + it('arms via existence polling when the worktrees dir appears later', async () => { installSubscribeMock() const commonDir = await makeCommonDir(false) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 9de2c8c3392..d719ca257bb 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -31,7 +31,8 @@ export async function startGitCommonWatch( visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, getStatusRefPaths: () => readonly string[] = () => [], - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + onOverflow?: () => void ): Promise { if (supportsNarrowWatch(platform)) { const [narrowWatch, primaryWatch] = await Promise.all([ @@ -42,7 +43,8 @@ export async function startGitCommonWatch( platform, visibility, onFullScan, - onWatchError + onWatchError, + onOverflow ), startGitCommonPrimaryWatch( target.path, @@ -60,6 +62,8 @@ export async function startGitCommonWatch( } } } + // Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS + // above, so this branch is defensive dead code in production, not a reachable fallback. return startGitCommonPolling( target.path, onEvents, diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 577235c86dd..eacd2cd133f 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -4,6 +4,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { cleanupUnusedWorktreePushTargetRemoteWithExec, + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, sameGitHubRemoteUrl, type GitRemoteExec, type WorktreePushTargetStore @@ -106,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 () => { @@ -240,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( @@ -253,6 +274,70 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { }) }) +describe('hasBranchConfigUsingRemote', () => { + it('requireExistingBranch: false (default) protects on config alone, even if the branch is gone', async () => { + const exec = makeExec({ branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` }) + await expect(hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget())).resolves.toBe(true) + }) + + it('requireExistingBranch: true only protects when the referencing branch still exists', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\ncontributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) + + it('requireExistingBranch: true does not protect on a stale config entry from a deleted branch', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\n', stderr: '' } // contributor/fix no longer exists + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(false) + }) + + it('extracts branch names containing dots correctly', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.release/1.2.3.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'release/1.2.3\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) +}) + +describe('findWorktreeMetaReferencingRemote', () => { + it('scopes matches to the given repo id and excludes worktrees without a pushTarget', () => { + const store = storeOf({ + 'repo-1::/wt/a': forkTarget(), + 'repo-1::/wt/b': undefined, + 'repo-2::/wt/c': forkTarget() + }) + const matches = findWorktreeMetaReferencingRemote(store, 'repo-1', forkTarget()) + expect(matches.map((match) => match.worktreeId)).toEqual(['repo-1::/wt/a']) + }) +}) + describe('sameGitHubRemoteUrl', () => { it('matches SSH and HTTPS forms of the same GitHub fork', () => { expect( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 2bda01c5a89..09918ffe8f7 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -1,9 +1,12 @@ // Why: fork-PR worktrees can add a contributor's fork as a git remote. When such // a worktree is deleted we prune that remote, but only when it's truly unused. // This module holds that decision logic behind an injectable `execGit` boundary so -// the multi-fork cleanup matrix is unit-testable without a real repo. +// the multi-fork cleanup matrix is unit-testable without a real repo. The same +// predicates back the periodic sweep in `worktree-push-target-reconciliation.ts`, +// which inverts them over every `pr-*` remote instead of one removed worktree. import type { Store } from '../persistence' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -13,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) { @@ -29,61 +36,113 @@ export function sameGitHubRemoteUrl(left: string, right: string): boolean { ) } +/** A worktree metadata entry, in the same repo as `target`, whose pushTarget references it. */ +export type WorktreeMetaReferencingRemote = { worktreeId: string; meta: WorktreeMeta } + +// Exported so the reconciliation sweep can inspect *which* worktrees reference a remote +// (to check liveness/provenance) instead of only the single-target yes/no this file needs. +export function findWorktreeMetaReferencingRemote( + store: WorktreePushTargetStore, + repoId: string, + target: Pick +): WorktreeMetaReferencingRemote[] { + return Object.entries(store.getAllWorktreeMeta()) + .filter(([worktreeId, meta]) => { + // Why: git remotes are repo-local; matching metadata from another repo + // must not pin this repo's fork remote forever. + if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) + .map(([worktreeId, meta]) => ({ worktreeId, meta })) +} + function isPushTargetUsedByAnotherWorktree( store: WorktreePushTargetStore, removedWorktreeId: string, target: GitPushTarget ): boolean { const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) - return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { - // Why: git remotes are repo-local; matching metadata from another repo - // must not pin this repo's fork remote forever. - const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId - if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { - return false - } - const otherRemoteUrl = meta.pushTarget.remoteUrl - const targetRemoteUrl = target.remoteUrl - return ( - meta.pushTarget.remoteName === target.remoteName || - (typeof otherRemoteUrl === 'string' && - typeof targetRemoteUrl === 'string' && - sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) - ) - }) + return findWorktreeMetaReferencingRemote(store, removedRepoId, target).some( + ({ worktreeId }) => worktreeId !== removedWorktreeId + ) } -async function hasBranchConfigUsingRemote( +export type BranchConfigMatch = { branchName: string } + +// Exported for the sweep, which additionally verifies each matched branch still exists +// before treating it as a reason to keep the remote (`requireExistingBranch`). +export async function hasBranchConfigUsingRemote( execGit: GitRemoteExec, repoPath: string, - target: GitPushTarget + target: Pick, + options: { requireExistingBranch?: boolean } = {} +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + )) + } catch { + return false + } + const matches: BranchConfigMatch[] = [] + // Why: git config output can be large; avoid materializing line/split arrays here. + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseBranchRemoteConfigLine(line) + if (parsed && (parsed.value === target.remoteName || parsed.value === target.remoteUrl)) { + matches.push({ branchName: parsed.branchName }) + } + } + if (matches.length === 0) { + return false + } + if (!options.requireExistingBranch) { + return true + } + return branchesExist( + execGit, + repoPath, + matches.map((match) => match.branchName) + ) +} + +async function branchesExist( + execGit: GitRemoteExec, + repoPath: string, + branchNames: string[] ): Promise { try { const { stdout } = await execGit( - ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], repoPath ) - // Why: git config output can be large; avoid materializing line/split arrays here. - for (const line of iterateProcessOutputLines(stdout)) { - const value = readBranchRemoteConfigValue(line) - if (value === target.remoteName || value === target.remoteUrl) { - return true - } - } - return false + const existingBranches = new Set(iterateProcessOutputLines(stdout)) + return branchNames.some((branchName) => existingBranches.has(branchName)) } catch { return false } } -function readBranchRemoteConfigValue(line: string): string | null { +function parseBranchRemoteConfigLine(line: string): { branchName: string; value: string } | null { let index = 0 while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const keyStart = index while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const key = line.slice(keyStart, index) while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } @@ -96,13 +155,56 @@ function readBranchRemoteConfigValue(line: string): string | null { while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) { valueEnd -= 1 } - return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null + if (valueStart >= valueEnd) { + return null + } + const branchName = extractBranchNameFromConfigKey(key) + return branchName ? { branchName, value: line.slice(valueStart, valueEnd) } : null +} + +// `branch..remote` / `branch..pushRemote`; `` may itself contain dots +// (e.g. `release/1.2.3`), so only the known trailing suffix is stripped. +function extractBranchNameFromConfigKey(key: string): string | null { + const prefix = 'branch.' + if (!key.startsWith(prefix)) { + return null + } + const rest = key.slice(prefix.length) + const lastDot = rest.lastIndexOf('.') + if (lastDot <= 0) { + return null + } + const suffix = rest.slice(lastDot + 1) + if (suffix !== 'remote' && suffix !== 'pushRemote') { + return null + } + return rest.slice(0, lastDot) } 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( @@ -112,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-real-git.test.ts b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts new file mode 100644 index 00000000000..3c226b3475f --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts @@ -0,0 +1,173 @@ +// Real-binary coverage for the pr-* remote reconciliation sweep (#17828): the mocked-exec suite +// proves the decision matrix, but not that `git remote -v`, `git config --get-regexp`, and +// `git for-each-ref` are parsed correctly against real Git output. +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 } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { reconcileOrphanedPrRemotesWithExec } from './worktree-push-target-reconciliation' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd }) + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: forkPath, + remoteCreated: true, + ...overrides + } +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = { pushTarget } as unknown as WorktreeMeta + } + return { getAllWorktreeMeta: () => meta } +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pr-remote-reconcile-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.name', 'Orca Test'], repoPath) + await git(['config', 'user.email', 'orca@example.test'], repoPath) + await git(['config', 'commit.gpgSign', 'false'], repoPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + + // A second local "fork" repo the pr-* remote points at, so `remote add`/fetch behave normally. + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await git(['config', 'user.name', 'Orca Test'], forkPath) + await git(['config', 'user.email', 'orca@example.test'], forkPath) + await git(['config', 'commit.gpgSign', 'false'], forkPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], forkPath) + await git(['checkout', '-qb', 'contributor/fix'], forkPath) + await writeFile(join(forkPath, 'fork.txt'), 'fork change\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fork change'], forkPath) + + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + await git( + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/contributor/fix:refs/remotes/${FORK_REMOTE}/contributor/fix` + ], + repoPath + ) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('reconcileOrphanedPrRemotesWithExec against the real Git binary', () => { + it('leaves a user-created remote alone: naming/URL shape is not proof of provenance', async () => { + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({}), // no worktree metadata anywhere claims this remote + execGit, + [] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while a live worktree still references it', async () => { + const worktreePath = join(scratchDir, 'wt-live') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local'], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [worktreePath] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while its branch still exists (preserve-on-delete kept alive)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] // the worktree that created it is gone, but the branch it preserved is not + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('reclaims the remote once the branch that pinned it is deleted (path 2)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + // Delete only the ref, leaving the config behind, exactly as `update-ref -d` alone would -- + // proving the sweep checks branch existence rather than trusting stale config. + await rm(join(repoPath, '.git', 'refs', 'heads', 'contributor', 'fix')) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) + + it('reclaims a remote orphaned by a worktree removed outside Orca (path 3)', async () => { + const worktreePath = join(scratchDir, 'wt-externally-removed') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local-2'], repoPath) + // Simulate a plain `git worktree remove` the user ran outside Orca: Orca's metadata for + // that worktree is still sitting in the store (nothing told it to clean up), but the + // worktree itself is gone. + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [] // listWorktrees no longer reports it + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts new file mode 100644 index 00000000000..ed29809a151 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import { validateGitExecArgs } from '../../relay/git-exec-validator' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + _resetPrRemoteReconciliationRateLimitForTests, + isOrcaGeneratedPrRemoteName, + reconcileOrphanedPrRemotesWithExec +} from './worktree-push-target-reconciliation' + +type ExecMock = Mock + +const REPO_PATH = '/repo-root' +const REPO_ID = 'repo-1' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + remoteCreated: true, + ...overrides + } +} + +function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta { + return { pushTarget } as unknown as WorktreeMeta +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = metaWith(pushTarget) + } + return { getAllWorktreeMeta: () => meta } +} + +type ExecScript = { + remotes?: string + branchConfig?: string + localBranches?: string +} + +function makeExec(script: ExecScript = {}): ExecMock { + const { remotes = '', branchConfig = '', localBranches = '' } = script + return vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === '-v') { + return { stdout: remotes, stderr: '' } + } + if (args[0] === 'config') { + return { stdout: branchConfig, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: localBranches, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'remove') { + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +function remoteLines(entries: { name: string; url: string }[]): string { + return entries + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + +function removeCalls(exec: ExecMock): string[][] { + return exec.mock.calls + .map(([args]) => args) + .filter((args) => args[0] === 'remote' && args[1] === 'remove') +} + +describe('isOrcaGeneratedPrRemoteName', () => { + it('matches Orca-generated names, including disambiguated ones', () => { + expect(isOrcaGeneratedPrRemoteName('pr-head')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-head-2')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca-3')).toBe(true) + }) + + it('does not match unrelated remote names', () => { + expect(isOrcaGeneratedPrRemoteName('origin')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('upstream')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('project-remote')).toBe(false) + }) +}) + +describe('reconcileOrphanedPrRemotesWithExec', () => { + it('leaves a remote alone when no worktree metadata ever proves Orca created it (user-created, ambiguous)', async () => { + // Same naming shape a user could coincidentally pick; no pushTarget anywhere claims it. + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({}), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is not shaped like an Orca-generated pr-* remote', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: 'my-fork', url: FORK_URL }]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: { ...forkTarget(), remoteName: 'my-fork' } }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that a live worktree still references (path guard)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec, + ['/wt/a'] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is referenced by an existing branch (path 2, branch still kept)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'contributor/fix\nmain' + }) + // Metadata for the worktree that created it is gone, but the branch it preserved lives on. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('reclaims a remote whose protecting branch config is stale (path 2, branch since deleted)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + // Config line survives even though `contributor/fix` no longer exists. + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote left behind by a worktree removed outside Orca (path 3)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + // Metadata still records the (now-vanished) worktree's Orca-created pushTarget. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] // no live worktrees at all + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote even when the only referencing metadata lacks remoteCreated, as long as another entry proves provenance (path 1)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ + // The worktree whose removal originally bailed (legacy metadata, no remoteCreated flag)... + [worktreeId('/wt/legacy')]: forkTarget({ remoteCreated: false }), + // ...but a sibling that reused the remote correctly inherited ownership, and is also gone. + [worktreeId('/wt/sibling-gone')]: forkTarget({ remoteCreated: true }) + }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + }) + + it('never touches origin or upstream even if metadata is malformed', async () => { + const exec = makeExec({ + remotes: remoteLines([ + { name: 'origin', url: FORK_URL }, + { name: 'upstream', url: FORK_URL } + ]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('scopes provenance and liveness to the same repo (remotes are repo-local)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ 'repo-2::/wt/other-repo': forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + }) + + it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => { + // Exercise every branch (config probe, for-each-ref probe, and the reclaim itself). + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(exec.mock.calls.length).toBeGreaterThan(0) + for (const [args] of exec.mock.calls) { + expect(() => validateGitExecArgs(args)).not.toThrow() + } + }) +}) + +describe('reconcileOrphanedPrRemotes rate limiting', () => { + it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => { + expect(() => _resetPrRemoteReconciliationRateLimitForTests()).not.toThrow() + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts new file mode 100644 index 00000000000..e59da7bdfe7 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -0,0 +1,204 @@ +// Why: `pr-*` remotes Orca adds for fork-PR review are only ever pruned by +// `worktree-push-target-cleanup.ts`, and only when a *single* worktree removal +// triggers it. Three things escape that: (1) legacy/reused metadata missing the +// `remoteCreated` flag, (2) a "preserve branch on delete" pinning its remote via +// `branch.*.remote` config long after the worktree is gone, and (3) a worktree +// removed outside Orca entirely (no removal event ever fires). This sweep +// inverts the same safety predicates over every `pr-*` remote in the repo +// instead of one removal, so all three eventually get reclaimed. It never adds +// new safety logic — see `worktree-push-target-cleanup.ts` for the predicates. + +import { gitExecFileAsync } from '../git/runner' +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 { + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, + type GitRemoteExec, + type WorktreePushTargetStore +} from './worktree-push-target-cleanup' + +// Orca only ever mints `pr-head` or `pr--` (see `sanitizeRemoteName`), optionally +// disambiguated with `-2`..`-99` (see `ensureUniqueRemoteName`). The naming convention alone is +// not proof of provenance -- a user could name a remote `pr-foo` -- so this only narrows which +// remotes are even considered; `hasOrcaCreatedProvenance` below is the actual safety gate. +const ORCA_PR_REMOTE_NAME_PATTERN = + /^pr-(?:head|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?:-[0-9]{1,2})?$/ + +export function isOrcaGeneratedPrRemoteName(name: string): boolean { + return ORCA_PR_REMOTE_NAME_PATTERN.test(name) +} + +type PrRemoteCandidate = { name: string; url: string } + +async function listPrRemoteCandidates( + execGit: GitRemoteExec, + repoPath: string +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit(['remote', '-v'], repoPath)) + } 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 +} + +async function shouldReclaimPrRemote( + execGit: GitRemoteExec, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + remote: PrRemoteCandidate, + liveWorktreeKeys: ReadonlySet +): Promise { + const target: Pick = { + remoteName: remote.name, + remoteUrl: remote.url + } + const referencingEntries = findWorktreeMetaReferencingRemote(store, repoId, target) + // Provenance gate: only touch a remote some worktree's persisted pushTarget explicitly + // recorded Orca creating. Naming and URL shape are necessary but not sufficient proof. + if (!referencingEntries.some(({ meta }) => meta.pushTarget?.remoteCreated === true)) { + return false + } + const stillClaimedByLiveWorktree = referencingEntries.some(({ worktreeId }) => { + const key = worktreeIdComparisonKey(worktreeId) + return key !== null && liveWorktreeKeys.has(key) + }) + if (stillClaimedByLiveWorktree) { + return false + } + // A branch that still exists may push to this fork again later; only a branch that's + // actually gone (force-deleted, or deleted outside the "preserve on delete" flow) frees it. + if ( + await hasBranchConfigUsingRemote(execGit, repoPath, target, { requireExistingBranch: true }) + ) { + return false + } + return true +} + +// Exported for unit/real-git tests: the `execGit` seam and injected live-worktree paths let +// tests drive the sweep without a real repo (or with one, for the real-git coverage). +export async function reconcileOrphanedPrRemotesWithExec( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + execGit: GitRemoteExec, + liveWorktreePaths: readonly string[] +): Promise { + const liveWorktreeKeys = new Set( + liveWorktreePaths + .map((path) => worktreeIdComparisonKey(`${repoId}${WORKTREE_ID_SEPARATOR}${path}`)) + .filter((key): key is string => key !== null) + ) + const reclaimed: string[] = [] + for (const remote of await listPrRemoteCandidates(execGit, repoPath)) { + if (await shouldReclaimPrRemote(execGit, repoPath, repoId, store, remote, liveWorktreeKeys)) { + await execGit(['remote', 'remove', remote.name], repoPath) + reclaimed.push(remote.name) + } + } + return reclaimed +} + +// Why: the sweep costs a handful of git subprocesses (remote -v, worktree list, per-candidate +// config/for-each-ref); bound to once per repo per cooldown so bursts of removals don't repeat it. +const RECONCILE_COOLDOWN_MS = 60 * 60 * 1000 +const lastReconciledAtByRepoId = new Map() + +function shouldReconcileNow(repoId: string): boolean { + const last = lastReconciledAtByRepoId.get(repoId) + return last === undefined || Date.now() - last >= RECONCILE_COOLDOWN_MS +} + +export function _resetPrRemoteReconciliationRateLimitForTests(): void { + lastReconciledAtByRepoId.clear() +} + +function logReclaimed(repoPath: string, reclaimed: string[]): void { + if (reclaimed.length > 0) { + console.log( + `[worktrees] Reclaimed ${reclaimed.length} orphaned PR remote(s) in ${repoPath}: ${reclaimed.join(', ')}` + ) + } +} + +/** Best-effort, rate-limited sweep run alongside single-target cleanup (see call sites). */ +export async function reconcileOrphanedPrRemotes( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + gitOptions: { wslDistro?: string } = {} +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await listWorktrees(repoPath, gitOptions) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes for ${repoPath}`, error) + } +} + +/** SSH counterpart of {@link reconcileOrphanedPrRemotes}; the execution host owns the remotes. */ +export async function reconcileOrphanedPrRemotesSsh( + provider: SshGitProvider, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await provider.listWorktrees(repoPath) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => provider.exec(args, cwd), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes (SSH) for ${repoPath}`, error) + } +} diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index be718cfd10c..2d9670c2f9b 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 @@ -15,9 +17,17 @@ const FORK_SSH = 'git@github.com:contributor/orca.git' const FORK_HTTPS = 'https://github.com/contributor/orca.git' // 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: '' } } @@ -80,6 +90,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 +191,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 +255,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..59ef4c8fea5 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -49,6 +49,54 @@ export async function findRemoteForUrl( return null } +// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward): +// a single `remote get-url ` avoids the O(remotes) `findRemoteForUrl` scan +// 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) + const candidateUrl = stdout.trim() + if (candidateUrl === remoteUrl) { + return true + } + const target = parseGitHubOwnerRepo(remoteUrl) + const candidate = parseGitHubOwnerRepo(candidateUrl) + return Boolean( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) + } 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( execGit: GitRemoteExec, repoPath: string, @@ -103,16 +151,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 +196,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..68ed98ab35c --- /dev/null +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -0,0 +1,593 @@ +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.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 82d54f1d39a..0b985a0311f 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -104,12 +104,23 @@ import { type GitRemoteExec, type WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + reconcileOrphanedPrRemotes, + reconcileOrphanedPrRemotesSsh +} from './worktree-push-target-reconciliation' 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 { @@ -162,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 @@ -968,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) => @@ -989,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, @@ -1030,6 +1241,18 @@ export async function cleanupUnusedWorktreePushTargetRemote( } catch (error) { console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error) } + // Why: also catches remotes this specific removal couldn't reclaim (legacy metadata, + // a preserved branch since deleted, a worktree removed outside Orca) -- see + // worktree-push-target-reconciliation.ts. Rate-limited internally; safe to call every removal. + // Not awaited: a repo with a large backlog (the scenario this exists for) can have dozens of + // candidate remotes, each probed with a couple of git subprocesses -- that must never add + // latency to the worktree-removal call the user is waiting on. It catches its own errors. + void reconcileOrphanedPrRemotes( + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store, + gitOptions + ) } export async function configureCreatedWorktreePushTarget( @@ -1046,7 +1269,7 @@ export async function configureCreatedWorktreePushTarget( ) } -async function prepareWorktreePushTargetSsh( +export async function prepareWorktreePushTargetSsh( provider: SshGitProvider, repoPath: string, target: GitPushTarget, @@ -1090,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 { @@ -1113,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, @@ -1134,6 +1457,14 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh( error ) } + // Why: SSH counterpart of the sweep above -- the execution host owns these remotes. + // Not awaited for the same reason as the local path: never add sweep latency to removal. + void reconcileOrphanedPrRemotesSsh( + provider, + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store + ) } async function readRemoteEffectiveHooks( @@ -1739,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 () => @@ -1830,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, @@ -2165,130 +2490,139 @@ export async function createLocalWorktree( let lastExistingReviewNumber: number | null = null const shouldRetireGeneratedName = args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName) - const retiredNameRegistry = shouldRetireGeneratedName - ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) - : null - const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null - // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. - for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) { - effectiveSanitizedName = shouldRetireGeneratedName - ? getGeneratedWorktreeCreateCandidate( - sanitizedName, - suffix, - retiredNameRegistry?.exhaustedTiers - ) - : getWorktreeCreateCandidate(sanitizedName, suffix) - effectiveRequestedName = shouldRetireGeneratedName - ? effectiveSanitizedName - : requestedName.trim() - ? getWorktreeCreateCandidate(requestedName, suffix) - : effectiveSanitizedName - if (isRetiredName?.(effectiveSanitizedName)) { - continue - } - attempts += 1 - lastExistingReviewNumber = null + await timing.time('resolve_name', async () => { + const retiredNameRegistry = shouldRetireGeneratedName + ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) + : null + const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null + // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. + for ( + let suffix = 1, attempts = 0; + attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; + suffix += 1 + ) { + effectiveSanitizedName = shouldRetireGeneratedName + ? getGeneratedWorktreeCreateCandidate( + sanitizedName, + suffix, + retiredNameRegistry?.exhaustedTiers + ) + : getWorktreeCreateCandidate(sanitizedName, suffix) + effectiveRequestedName = shouldRetireGeneratedName + ? effectiveSanitizedName + : requestedName.trim() + ? getWorktreeCreateCandidate(requestedName, suffix) + : effectiveSanitizedName + if (isRetiredName?.(effectiveSanitizedName)) { + continue + } + attempts += 1 + lastExistingReviewNumber = null - branchName = await resolveCreateBranchName( - repo.path, - selectedExistingLocalBranchName - ? selectedExistingLocalBranchName - : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), - effectiveSanitizedName, - settings, - username, - localWorktreeGitOptions - ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - localWorktreeGitOptions - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. - selectedExistingLocalBranchName = branchName - } - lastBranchConflictKind = checkoutExistingBranch - ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) - const allowedPushTargetRemoteConflict = - lastBranchConflictKind && - isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) - if (lastBranchConflictKind) { - if (allowedPushTargetRemoteConflict) { - lastExistingPR = null - let lookupFailed = false - const selectedReview = getSelectedReviewBranch(args) - if (selectedReview?.provider === 'github') { - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - lookupFailed = true - } - if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastBranchConflictKind = null - } else if (lastExistingPR) { - lastExistingReviewNumber = lastExistingPR.number - } - } else if (selectedReview) { - let hostedReview: Awaited> = null - try { - hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) - } catch { - lookupFailed = true - } - if (!lookupFailed && hostedReview?.matchesSelected) { - lastBranchConflictKind = null - } else if (hostedReview) { - lastExistingReviewNumber = hostedReview.number + branchName = await resolveCreateBranchName( + repo.path, + selectedExistingLocalBranchName + ? selectedExistingLocalBranchName + : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), + effectiveSanitizedName, + settings, + username, + localWorktreeGitOptions + ) + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. + selectedExistingLocalBranchName = branchName + } + lastBranchConflictKind = checkoutExistingBranch + ? null + : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) + const allowedPushTargetRemoteConflict = + lastBranchConflictKind && + isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) + if (lastBranchConflictKind) { + if (allowedPushTargetRemoteConflict) { + lastExistingPR = null + let lookupFailed = false + const selectedReview = getSelectedReviewBranch(args) + if (selectedReview?.provider === 'github') { + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + lookupFailed = true + } + if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastBranchConflictKind = null + } else if (lastExistingPR) { + lastExistingReviewNumber = lastExistingPR.number + } + } else if (selectedReview) { + let hostedReview: Awaited> = null + try { + hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) + } catch { + lookupFailed = true + } + if (!lookupFailed && hostedReview?.matchesSelected) { + lastBranchConflictKind = null + } else if (hostedReview) { + lastExistingReviewNumber = hostedReview.number + } } } } - } - if (lastBranchConflictKind) { - continue - } - - // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. - if (suffix > 1 && !checkoutExistingBranch) { - lastExistingPR = null - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - // GitHub API may be unreachable, rate-limited, or token missing - } - if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastExistingReviewNumber = lastExistingPR.number + if (lastBranchConflictKind) { continue } - } - worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), - workspaceRoot - ) - if (existsSync(worktreePath)) { - continue - } + // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. + if (suffix > 1 && !checkoutExistingBranch) { + lastExistingPR = null + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + // GitHub API may be unreachable, rate-limited, or token missing + } + if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastExistingReviewNumber = lastExistingPR.number + continue + } + } - resolved = true - break - } + worktreePath = ensurePathWithinWorkspace( + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + workspaceRoot + ) + if (existsSync(worktreePath)) { + continue + } + + resolved = true + break + } + }) if (!resolved) { // Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner. - if (lastExistingReviewNumber !== null) { + // Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s + // narrowing does not reach the message. + const existingReviewNumber = lastExistingReviewNumber + if (existingReviewNumber !== null) { throw new Error( - `Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.` + `Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.` ) } if (lastBranchConflictKind) { @@ -2336,17 +2670,9 @@ export async function createLocalWorktree( } emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) - let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { - // Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry. - preparedPushTarget = await prepareWorktreePushTarget( - repo.path, - args.pushTarget, - 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 && @@ -2366,7 +2692,7 @@ export async function createLocalWorktree( addResult = (await timing.time('git_worktree_add', async () => { if (sparseDirectories.length === 0 && !checkoutExistingBranch) { - const preparedResult = await consumePreparedWorktreeCreate({ + const prepared = await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot, worktreePath, @@ -2375,9 +2701,19 @@ export async function createLocalWorktree( refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) }) - if (preparedResult) { - return preparedResult + timing.recordPreparedCheckout( + prepared.status === 'hit' + ? { status: 'hit', retargeted: prepared.retargeted } + : { status: 'miss', reason: prepared.reason } + ) + if (prepared.status === 'hit') { + return prepared.result } + } else { + timing.recordPreparedCheckout({ + status: 'miss', + reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch' + }) } if (sparseDirectories.length > 0) { if (checkoutExistingBranch) { @@ -2476,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-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index d01efc489da..abc711bbd9b 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -633,7 +633,10 @@ describe('registerWorktreeHandlers', () => { })) as { setup?: unknown startupTerminal?: { spawned: boolean; surface?: string } - timing?: { phases: { phase: string }[] } + timing?: { + phases: { phase: string }[] + preparedCheckout?: { status: string; reason?: string } + } } expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), @@ -695,6 +698,8 @@ describe('registerWorktreeHandlers', () => { 'spawn_startup_terminal' ]) ) + // Nothing warmed this repo, so the create must report the cold path rather than stay silent. + expect(result.timing?.preparedCheckout).toEqual({ status: 'miss', reason: 'none_armed' }) }) it('returns the wrapped setup command when startup spawned but setup creation failed', async () => { diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index 0996df03cb4..725f2df8c1f 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -205,14 +205,14 @@ describe('registerWorktreeHandlers', () => { } ]) - const result = await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'improve-dashboard', sparseCheckout: { directories: [' packages/web ', 'apps\\api\\', 'packages/web/'], presetId: 'preset-1' } - }) + })) as { timing?: { preparedCheckout?: { status: string; reason?: string } } } expect(addWorktreeMock).not.toHaveBeenCalled() expect(addSparseWorktreeMock).toHaveBeenCalledWith( @@ -240,6 +240,11 @@ describe('registerWorktreeHandlers', () => { sparsePresetId: 'preset-1' }) }) + // A sparse create can never claim a prepared checkout; say so rather than looking like a miss. + expect(result.timing?.preparedCheckout).toEqual({ + status: 'miss', + reason: 'sparse_checkout' + }) }) it('retires a generated sparse name when creation rollback also fails', async () => { 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-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 6a398e9b02d..b3231da86f6 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../shared/execution-host' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, @@ -443,7 +447,76 @@ describe('registerWorktreeHandlers', () => { expect(store.removeWorktreeMeta).not.toHaveBeenCalled() }) - it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => { + // Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired + // client needs this path to ever drop them (#17776). + it('retires runtime-host metadata an authoritative scan proved gone', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + const runtimeRepo = { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId + } + const metaById: Record> = { + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }), + 'repo-1::/home/orca/other-host': makeWorktreeMeta({ + hostId: toSshExecutionHostId('target-a') + }) + } + store.getRepos.mockReturnValue([runtimeRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + delete metaById[worktreeId] + }) + + const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: runtimeRepo.id, + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host'] + }) + + // The row stamped to another host needs that host's own scan, not this one's. + expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] }) + expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith( + 'repo-1::/home/orca/deleted', + runtimeHostId + ) + }) + + // A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal + // comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is + // contradictory ownership evidence, so no owner resolves at all. + it('refuses to retire a connection-backed repo under a runtime host id', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId, + connectionId: 'target-a' + } + ]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }) + }) + + expect( + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted'] + }) + ).toEqual({ forgottenWorktreeIds: [] }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses to retire metadata for non-executing hosts and unowned repos', async () => { const sshRepo = { id: 'repo-1', path: '/remote/repo-a', 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.ts b/src/main/ipc/worktrees.ts index ff58a561ee3..3fd2fb41908 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,7 +17,10 @@ import { registerSparseCheckoutCacheInvalidation } from './worktrees/listing/reg import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers' import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers' import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers' -import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context' +import { + createWorktreeRemovalRegistry, + type WorktreeIpcContext +} from './worktrees/worktree-ipc-context' registerDetectedWorktreeScanInvalidation() @@ -66,7 +69,7 @@ export function registerWorktreeHandlers( runtime, ...(options ? { options } : {}), detectedWorktreeCancellations: createSenderScopedRequestCancellations(), - worktreeRemovalsInFlight: new Map() + worktreeRemovalsInFlight: createWorktreeRemovalRegistry() } // Remove all stale registrations before installing any replacement handler. diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 775a0859790..f371529963c 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -4,7 +4,10 @@ import type { CreateWorktreeResult, AdoptProvisionedRootArgs } from '../../../../shared/worktree/create-types' -import { withWorktreeSpan } from '../../../observability/instrumentation' +import { + addWorktreeCreatePhaseAttributes, + withWorktreeSpan +} from '../../../observability/instrumentation' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import type { WorkspaceSource } from '../../../../shared/telemetry-events' import { @@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi async (_event, rawArgs: CreateWorktreeArgs): Promise => { const args = normalizeLinkedWorkItemFields(rawArgs) // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. - return withWorktreeSpan({ stage: 'create' }, async () => { + return withWorktreeSpan({ stage: 'create' }, async (span) => { const repo = store.getRepo(args.repoId) if (!repo) { throw new Error(`Repo not found: ${args.repoId}`) @@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw error } finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + if (result.timing) { + addWorktreeCreatePhaseAttributes(span, result.timing) + } // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. track('workspace_created', { 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-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 0d47f3638c2..4467506e790 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -103,11 +103,21 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + // Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from + // gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them. + if ( + (parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') || + worktreeIds.length === 0 + ) { return nothingForgotten } + // No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both + // a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence` + // calls that pair contradictory and one non-owned candidate voids the whole lookup. A second + // check would be unreachable, and unreachable code on a destructive path reads as a guarantee + // it is not making. const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { + if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) { return nothingForgotten } // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips 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/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts index 5455a71d06e..153e4b723ec 100644 --- a/src/main/ipc/worktrees/worktree-ipc-context.ts +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -14,3 +14,18 @@ export type WorktreeIpcContext = { detectedWorktreeCancellations: SenderScopedRequestCancellations worktreeRemovalsInFlight: Map } + +// Why: removal and forget both delete refs, and a ref deletion has to take the +// `packed-refs` lock. Idle ref maintenance needs a process-wide view of that +// registry so it never packs while one is running. +let activeWorktreeRemovals: ReadonlyMap | null = null + +export function createWorktreeRemovalRegistry(): Map { + const registry = new Map() + activeWorktreeRemovals = registry + return registry +} + +export function hasWorktreeRemovalsInFlight(): boolean { + return (activeWorktreeRemovals?.size ?? 0) > 0 +} 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/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 2f7d1da05dd..452b5cd155c 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer' import { _gitSpanSamplingBucketCountForTests, _resetGitSpanSamplingForTests, + addWorktreeCreatePhaseAttributes, withGitSpan } from './instrumentation' @@ -167,3 +168,84 @@ describe('withGitSpan sampling', () => { expect(_gitSpanSamplingBucketCountForTests()).toBe(1) }) }) + +describe('addWorktreeCreatePhaseAttributes', () => { + function capture(): { + attributes: Record + span: Parameters[0] + } { + const attributes: Record = {} + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value + } + } as unknown as Parameters[0] + return { attributes, span } + } + + it('counts concurrent phases once when measuring unattributed time', () => { + const { attributes, span } = capture() + // Create resolves shared directories and .worktreeinclude concurrently; summing their + // durations would claim 400ms of coverage for a 200ms window. + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 1000, + phases: [ + { phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 }, + { phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 } + ] + }) + + expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200) + expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150) + // Covered wall clock is 100..300, so 800ms is genuinely unaccounted for. + expect(attributes['worktree.create.unattributed_ms']).toBe(800) + }) + + it('sums disjoint phases and never reports negative unattributed time', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 500, + phases: [ + { phase: 'resolve_name', startedAtMs: 0, durationMs: 100 }, + { phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 } + ] + }) + + expect(attributes['worktree.create.total_ms']).toBe(500) + expect(attributes['worktree.create.unattributed_ms']).toBe(200) + }) + + it('records a prepared-checkout hit and whether it had to be retargeted', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 900, + phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }], + preparedCheckout: { status: 'hit', retargeted: true } + }) + + expect(attributes['worktree.create.prepared_checkout']).toBe('hit') + expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true) + expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined() + expect(attributes['worktree.create.unattributed_ms']).toBe(500) + }) + + it('records why a create missed the prepared checkout', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 8_000, + phases: [], + preparedCheckout: { status: 'miss', reason: 'base_mismatch' } + }) + + expect(attributes['worktree.create.prepared_checkout']).toBe('miss') + expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch') + expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined() + }) + + it('stays silent on paths that never consult the prepared checkout', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 10, phases: [] }) + + expect(attributes['worktree.create.prepared_checkout']).toBeUndefined() + }) +}) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bab57b74f64..8569ab6ef04 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -21,6 +21,7 @@ // itself becomes a `noopSpan` that swallows all calls — call sites do not // need to branch on whether tracing is on. +import type { PreparedCheckoutOutcome } from '../../shared/worktree/create-types' import { startSpan, withSpan, type ActiveSpan } from './tracer' const GIT_FAST_SUCCESS_THRESHOLD_MS = 250 @@ -202,10 +203,11 @@ export type WorktreeSpanArgs = { readonly path?: string } -/** Wrap a worktree-setup phase in a `worktree.` span. */ +/** Wrap a worktree-setup phase in a `worktree.` span. The callback receives the span so a + * create can attach its own phase breakdown; the git children alone leave the waits invisible. */ export async function withWorktreeSpan( meta: WorktreeSpanArgs, - fn: () => Promise + fn: (span: ActiveSpan) => Promise ): Promise { return withSpan( `worktree.${meta.stage}`, @@ -214,12 +216,80 @@ export async function withWorktreeSpan( if (meta.path) { span.setAttribute('worktree.path', meta.path) } - return await fn() + return await fn(span) }, { attributes: { kind: 'worktree' } } ) } +type WorktreeCreatePhaseTiming = { + readonly phase: string + readonly startedAtMs: number + readonly durationMs: number +} + +/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing + * durations double-counts and would report overlap as coverage the phases never had. */ +function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number { + const intervals = [...phases] + .map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const) + .sort((left, right) => left[0] - right[0]) + let covered = 0 + let openedAt: number | null = null + let closesAt = 0 + for (const [start, end] of intervals) { + if (openedAt === null) { + openedAt = start + closesAt = end + continue + } + if (start <= closesAt) { + closesAt = Math.max(closesAt, end) + continue + } + covered += closesAt - openedAt + openedAt = start + closesAt = end + } + return openedAt === null ? 0 : covered + (closesAt - openedAt) +} + +type WorktreePhaseInterval = Pick + +/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in + * the recorder, so they are safe to key on; nothing here carries a branch name or a path. */ +export function addWorktreeCreatePhaseAttributes( + span: ActiveSpan, + timing: { + totalDurationMs: number + phases: readonly WorktreeCreatePhaseTiming[] + preparedCheckout?: PreparedCheckoutOutcome + } +): void { + span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs)) + if (timing.preparedCheckout) { + span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status) + if (timing.preparedCheckout.status === 'hit') { + // A retargeted hit still pays a reset, so it must not be read as a free hit. + span.setAttribute( + 'worktree.create.prepared_checkout_retargeted', + timing.preparedCheckout.retargeted + ) + } else { + span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason) + } + } + for (const phase of timing.phases) { + span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs)) + } + // What the phases do not cover is the number that matters when create feels slow for no visible + // reason, so name it rather than leaving it to subtraction. + span.setAttribute( + 'worktree.create.unattributed_ms', + Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) + ) +} + /** Closed set so a typo can't silently mint an orphan span name. */ export type WorktreeRemoveStage = | 'archive_hook' diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index 1586a0e0120..8cf58dd1bd5 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -66,6 +66,24 @@ export function rekeyOwnerKey( return null } +/** + * Every worktree locator an owner key could name. + * + * Two readings, because one key can be both: with a repo literally named `worktree`, + * `worktree::/p` is a `::` locator AND parses as a `worktree:` workspace key naming + * repo `` (empty). `ownerKeyBelongsToRepo` accepts either, so a caller that reasons about a key + * without a repo id in hand has to consider both or it will disagree with the predicate. + */ +export function ownerKeyWorktreeIds(ownerKey: string): string[] { + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + const scope = parseWorkspaceKey(ownerKey) + return scope?.type === 'worktree' && scope.worktreeId !== rawOwnerKey + ? [rawOwnerKey, scope.worktreeId] + : [rawOwnerKey] +} + export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { const rawOwnerKey = isWorktreeHostIdentity(ownerKey) ? getWorktreeIdFromHostIdentity(ownerKey) 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-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 4081672c821..a894b8a4c63 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => { it('moves persisted mobile selections across reloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' })) store.setMobileClientTabSelections({ 'device-a': { [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } diff --git a/src/main/persistence-cross-host-pane-identity.test.ts b/src/main/persistence-cross-host-pane-identity.test.ts index 2b6a70da934..479d3727837 100644 --- a/src/main/persistence-cross-host-pane-identity.test.ts +++ b/src/main/persistence-cross-host-pane-identity.test.ts @@ -10,6 +10,7 @@ import { createStore, writeDataFile, readDataFile, + makeRepo, makeTerminalTab } from './persistence-test-harness' @@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => { it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => { writeDataFile({ schemaVersion: 1, + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + repos: [ + makeRepo({ id: 'repo-local', path: '/repo-local' }), + makeRepo({ id: 'repo-a', path: '/repo-a' }) + ], workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a') diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts new file mode 100644 index 00000000000..3a7a3372b3c --- /dev/null +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -0,0 +1,240 @@ +// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could +// reach them -- the missing-directory prune is gated on the repo still being registered, and a +// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the +// owning host's removal never reached it (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import type { PersistedState } from '../shared/persisted-state-types' +import { + testState, + createStore, + writeDataFile, + readDataFile, + makeRepo, + makeTerminalTab +} from './persistence-test-harness' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const LIVE_REPO = 'live-repo' +const GONE_REPO = 'gone-repo' +const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live` +const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan` +const RUNTIME_HOST = 'runtime:env-a' + +const sleepingAgentFor = (worktreeId: string, tabId = 'tab-1') => ({ + [`${tabId}:leaf-1`]: { + paneKey: `${tabId}:leaf-1`, + tabId, + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'sess-1' }, + prompt: 'sleeping', + state: 'waiting' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' as const + } +}) + +const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })] + }, + activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const }, + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + // The residue `profile-project-session-field-disposition` flags as leaking on repo removal. + sleepingAgentSessionsByPaneKey: sleepingAgentFor(worktreeId, tabId) +}) + +describe('deregistered repo residue', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' }) + seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local') + seed.flush() + + // Deregister by hand: the point is that a row can outlive its repo however that happened. + const persisted = readDataFile() as PersistedState + persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO) + writeDataFile(persisted) + + const reloaded = await createStore() + reloaded.flush() + const swept = readDataFile() as PersistedState + + expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE]) + expect(swept.worktreeIdentityAliases).not.toHaveProperty( + composeWorktreeHostIdentity('local', GONE_WORKTREE) + ) + expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1) + const session = swept.workspaceSession + expect(session.tabsByWorktree).toEqual({}) + expect(session.lastVisitedAtByWorktreeId).toEqual({}) + expect(session.activeTabTypeByWorktree).toEqual({}) + expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + }) + + it("sweeps a remote host's session partition the owning host's removal can never reach", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: sessionFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree).toEqual({}) + expect(partition.activeTabTypeByWorktree).toEqual({}) + }) + + it('keeps rows for every registered repo, on any execution host', async () => { + const remoteWorktree = `${LIVE_REPO}::/home/user/remote` + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })], + worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } }, + workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined() + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1) + // Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite. + expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1) + }) + + it('leaves folder-workspace session rows alone: their keys name no repo', async () => { + const workspaceKey = folderWorkspaceKey('folder-1') + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [workspaceKey]: 7 } + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({ + [workspaceKey]: 7 + }) + }) + + // Regression: the pane-keyed records are pruned by the worktreeId they name, not by their own key, + // so an orphan whose ONLY residue is a sleeping agent survived -- and re-seeded the sweep on every + // launch, so the store never self-cleared and every load scheduled another save. + it("drops a sleeping agent that is the orphan repo's only residue, and self-clears", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: sleepingAgentFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + // On disk, not just in memory: if the flush had not persisted the cleanup, the next load would + // silently redo it and the self-clearing assertion below would pass without meaning anything. + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + + // Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has + // no work. Before the fix this stayed non-empty forever and every load scheduled another save. + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + + // The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches + // them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck. + it.each([ + { label: 'activeWorktreeId', session: { activeWorktreeId: GONE_WORKTREE } }, + // Canonical `worktree:` form, which needs unwrapping before the repo id is visible. + { + label: 'activeWorkspaceKey', + session: { activeWorkspaceKey: worktreeWorkspaceKey(GONE_WORKTREE) } + }, + { + label: 'activeWorktreeIdsOnShutdown', + session: { activeWorktreeIdsOnShutdown: [GONE_WORKTREE] } + } + ])("clears $label when it is the orphan repo's only residue", async ({ session }) => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session } + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.activeWorktreeId ?? null).toBeNull() + expect(partition.activeWorkspaceKey ?? null).toBeNull() + expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([]) + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + + // Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes. + it('leaves a profile with no orphans byte-identical across reloads', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local') + seed.flush() + + const canonicalizing = await createStore() + canonicalizing.flush() + const canonical = JSON.stringify(readDataFile()) + + const reloaded = await createStore() + reloaded.flush() + + expect(JSON.stringify(readDataFile())).toBe(canonical) + }) +}) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 023737ed386..aa2e46e52fd 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => { } }) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` })) + it('migrates a legacy workspaceSession blob into the local partition', async () => { writeDataFile({ schemaVersion: 1, @@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-ssh'), workspaceSessionsByHostId: { 'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty') }, @@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-a', 'repo-b'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'), 'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b') @@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => { it('removes one orphaned worktree with a host-scoped topology fence', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' })) const worktreeId = 'repo-gone::/workspace/stale' const session = { ...makeHostSession('repo-gone'), @@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. @@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), // A projected/truncated write can leave a top-level field the wrong type; @@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } @@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:env-a': { ...makeHostSession('runtime-repo'), diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 6d0c0f11d5e..934ab44e1cb 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -150,6 +150,8 @@ describe('Store', () => { it('does not restore a terminal tab after its durable close flush returns', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) const worktreeId = 'repo-1::/tmp/worktree-1' const tabId = 'terminal-1' const session: WorkspaceSessionState = { diff --git a/src/main/persistence-native-chat-tab-view-mode.test.ts b/src/main/persistence-native-chat-tab-view-mode.test.ts index 3e3544c7495..9bcaab15494 100644 --- a/src/main/persistence-native-chat-tab-view-mode.test.ts +++ b/src/main/persistence-native-chat-tab-view-mode.test.ts @@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => { const WORKTREE = 'repo1::/worktree' writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: {}, diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 1653f78297e..66f3fc2c32e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -737,7 +737,10 @@ describe('Store', () => { it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { const store = await createStore() - // A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only. + // A meta on the old SSH host with no repo row for that host — the re-point must still be + // persisted, not memory-only. The repo id stays registered so the load-time orphan sweep, + // which only reads repo ids, leaves the row alone. + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') @@ -787,6 +790,7 @@ describe('Store', () => { it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorkspaceSession( { activeRepoId: null, diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index dc3a3c7ebb5..9af63ca7ccd 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -708,7 +708,7 @@ describe('Store', () => { } writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: { 'repo1::/worktree-a': { status: 'active' }, 'repo1::/worktree-b': { status: 'active' } diff --git a/src/main/persistence-ssh-targets-and-pane-keys.test.ts b/src/main/persistence-ssh-targets-and-pane-keys.test.ts index 6c186ade077..c11ecbf0bc2 100644 --- a/src/main/persistence-ssh-targets-and-pane-keys.test.ts +++ b/src/main/persistence-ssh-targets-and-pane-keys.test.ts @@ -346,7 +346,7 @@ describe('Store', () => { const acknowledgedAt = 1_700_000_000_000 writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { @@ -408,7 +408,7 @@ describe('Store', () => { writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index ef5e83745be..372e8b0e33b 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -166,6 +166,8 @@ describe('Store', () => { describe('mobileClientTabSelectionsByDeviceId', () => { it('persists device tab selections across reloads and drops malformed payloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) store.setMobileClientTabSelections({ 'device-a': { 'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} } @@ -188,6 +190,7 @@ describe('Store', () => { it('prunes selections for a removed repo worktree', async () => { const store = await createStore() store.addRepo(makeRepo()) + store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' })) store.setMobileClientTabSelections({ 'device-a': { 'r1::/tmp/wt': { 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/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/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 095090ee1ca..535108845e1 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -12,10 +12,14 @@ import { import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' 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' import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' @@ -129,6 +133,33 @@ export class RepoLifecycleOperations { scheduleSave(this[repoLifecycleOperationsContext].scheduling) } + /** + * Drop every persisted row owned by a repo id that is no longer registered. + * + * Runs at load because no removal path can: `removeProject` only fires while the repo is still in + * `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client + * never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner + * that could object, so this ignores the session-ownership and local-execution-host gates the + * missing-directory sweeper needs. + */ + sweepDeregisteredRepoResidue(): string[] { + const state = this[repoLifecycleOperationsContext].runtime.state + const orphanRepoIds = collectDeregisteredRepoIds(state) + if (orphanRepoIds.size === 0) { + return [] + } + for (const repoId of orphanRepoIds) { + pruneWorktreeStateForRepo(this, repoId, null) + state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId) + state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + state.workspaceSessionsByHostId, + repoId + ) + } + pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds) + return [...orphanRepoIds] + } + updateRepo( id: string, updates: Partial< @@ -191,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( @@ -212,6 +246,26 @@ export function pruneMobileClientTabSelections( } } +function pruneDeregisteredRepoUiResidue( + ui: PersistedState['ui'], + orphanRepoIds: ReadonlySet +): void { + const isOrphanWorktree = (worktreeId: string): boolean => + orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId)) + if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) { + ui.lastActiveRepoId = null + } + if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) { + ui.lastActiveWorktreeId = null + } + ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] + for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete ui.showDotfilesByWorktree?.[worktreeId] + } + } +} + export function getRepoUpdateOperations( owner: RepoLifecycleOperations ): RepoUpdatePersistenceOperations { 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/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/store.ts b/src/main/persistence/loading-store/store.ts index 888c0092ed2..017583e8f86 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -64,6 +64,9 @@ export class Store { ) const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() this.domains.adaptation.hydrateFolderWorkspaceDiffComments() + // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to + // still be registered, so rows outlive their owner without one (#17776). + const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -78,7 +81,12 @@ export class Store { this.state.legacyPaneKeyAliasEntries = entries scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { scheduleSave(this.domains.scheduling) } } 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.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index 639fda7b836..73e3be63e8b 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'node:crypto' +import { isFinalAutomationRunStatus } from '../../../shared/automations-types' +import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import type { Automation, AutomationDispatchResult, @@ -182,6 +184,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/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts new file mode 100644 index 00000000000..c52bddbb712 --- /dev/null +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -0,0 +1,108 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { splitWorktreeId } from '../../../shared/worktree/id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' +import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity' + +/** + * Repo ids that still own persisted rows but no longer appear in `state.repos`. + * + * Why nothing else finds them: every other sweeper is gated on the repo still being registered, so + * deregistering a project stranded the rows it owned permanently — including a paired client's + * mirror of a remote host's session partition, which no local repo removal can reach (#17776). + */ +export function collectDeregisteredRepoIds(state: PersistedState): Set { + const liveRepoIds = new Set(state.repos.map((repo) => repo.id)) + const orphanRepoIds = new Set() + // Only a full `::` locator seeds the set. A bare key -- a folder workspace id, a + // repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and + // guessing wrong here deletes live session state. + const addWorktreeId = (worktreeId: string | null | undefined): void => { + const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined + if (repoId && !liveRepoIds.has(repoId)) { + orphanRepoIds.add(repoId) + } + } + /** + * Seed from an owner key, which can read as two different locators (see `ownerKeyWorktreeIds`). + * All or nothing: if either reading names a live repo the key is that repo's, and seeding the + * other reading would hand the removal pass -- which accepts either -- a live row to delete. + */ + const addOwnerKey = (ownerKey: string): void => { + const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => { + const repoId = splitWorktreeId(worktreeId)?.repoId + return repoId ? [repoId] : [] + }) + if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) { + for (const repoId of repoIds) { + orphanRepoIds.add(repoId) + } + } + } + + // Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are + // bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd + // that still holds a prior occupant's agent state. + for (const worktreeId of Object.keys(state.worktreeMeta)) { + addWorktreeId(worktreeId) + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + addWorktreeId(getWorktreeIdFromHostIdentity(alias)) + } + for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) { + addWorktreeId(childId) + addWorktreeId(lineage.parentWorktreeId) + } + for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + addOwnerKey(childKey) + addOwnerKey(lineage.parentWorkspaceKey) + } + for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) { + for (const worktreeId of Object.keys(selections)) { + addWorktreeId(worktreeId) + } + } + const sessions: (WorkspaceSessionState | undefined)[] = [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}) + ] + for (const session of sessions) { + if (!session) { + continue + } + for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { + for (const ownerKey of Object.keys( + (session[field] as Record | undefined) ?? {} + )) { + addOwnerKey(ownerKey) + } + } + for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + // Pruned by bespoke rules rather than by owner key, so the loop above never reaches them. + for (const ownerKey of [ + session.activeWorktreeId, + session.activeWorkspaceKey, + ...(session.activeWorktreeIdsOnShutdown ?? []) + ]) { + if (ownerKey) { + addOwnerKey(ownerKey) + } + } + // Not seeded from `terminalTopologyRevisionByRepoId`: its keys are bare repo ids by contract, + // and a bare key is exactly what `addWorktreeId` refuses to trust. Rows there are removed once + // any locator seeds their repo id, which every repo that ever opened a terminal has. + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + addWorktreeId(record.worktreeId) + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + addWorktreeId(tombstone.worktreeId) + } + } + return orphanRepoIds +} 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/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f6d17b24aa2..a41f7c6319d 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -2,6 +2,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getExecutionHostIdFromWorktreeHostIdentity, @@ -58,10 +59,26 @@ export function pruneWorktreeStateForRepo( } } } - collectPrefixedKeys(Object.keys(state.worktreeMeta)) - collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) - for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + // Why the pane-keyed records contribute owner keys: they are pruned by the worktreeId they name, + // not by their own key, so a worktree with no meta and no visit row would otherwise keep its + // sleeping agents and tombstones forever -- and keep re-seeding the orphan sweep every load. + const collectScannedRecordOwners = (session: WorkspaceSessionState | undefined): void => { collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + collectPrefixedKeys( + Object.values(session?.sleepingAgentSessionsByPaneKey ?? {}).map( + (record) => record.worktreeId + ) + ) + collectPrefixedKeys( + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).map( + (tombstone) => tombstone.worktreeId + ) + ) + } + collectPrefixedKeys(Object.keys(state.worktreeMeta)) + collectScannedRecordOwners(state.workspaceSession) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectScannedRecordOwners(session) } for (const key of Object.keys(state.worktreeMeta)) { 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/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-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/rate-limits/claude-fetcher-fable-usage.test.ts b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts index 2a84cc4f2af..50617d95bb4 100644 --- a/src/main/rate-limits/claude-fetcher-fable-usage.test.ts +++ b/src/main/rate-limits/claude-fetcher-fable-usage.test.ts @@ -144,6 +144,51 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) + it('maps a scoped Fable window whose display name carries a point release', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 36 }, + seven_day: { utilization: 73 }, + // Discriminating: a passing scope match must beat this fallback. + fable_weekly: { utilization: 12 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 64, + resets_at: '2026-07-17T20:00:00.099908+00:00', + is_active: true, + scope: { model: { display_name: 'Fable 5.1' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 64, + resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') + } + }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts index 9565a064514..e29bef5f48e 100644 --- a/src/main/rate-limits/claude-oauth-usage-request.ts +++ b/src/main/rate-limits/claude-oauth-usage-request.ts @@ -32,13 +32,17 @@ async function ensureProxyFromEnvironment(): Promise { }).catch(() => {}) } +// Why: the scope name carries the shipped version once a point release exists +// ("Fable 5.1"), so exact equality would drop the window. +const FABLE_SCOPE_RE = /^fable\b/ + function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { const scoped = Array.isArray(data.limits) ? data.limits.find( (limit) => limit?.kind === 'weekly_scoped' && Number.isFinite(limit.percent) && - limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable' + FABLE_SCOPE_RE.test(limit.scope?.model?.display_name?.trim().toLowerCase() ?? '') ) : undefined return ( diff --git a/src/main/repo-maintenance-idle-gate.test.ts b/src/main/repo-maintenance-idle-gate.test.ts new file mode 100644 index 00000000000..78728f3a43a --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const isOnBatteryPowerMock = vi.hoisted(() => vi.fn(() => false)) +const hasPendingPreparationsMock = vi.hoisted(() => vi.fn(() => false)) +const hasRemovalsInFlightMock = vi.hoisted(() => vi.fn(() => false)) +const setProbeMock = vi.hoisted(() => vi.fn()) +const disposeMock = vi.hoisted(() => vi.fn(async () => {})) +const postponeMock = vi.hoisted(() => vi.fn()) +const powerListeners = vi.hoisted(() => new Map void>()) +const appListeners = vi.hoisted(() => new Map void>()) + +vi.mock('electron', () => ({ + app: { + on: (event: string, listener: () => void) => appListeners.set(event, listener), + off: (event: string) => appListeners.delete(event) + }, + powerMonitor: { + isOnBatteryPower: isOnBatteryPowerMock, + on: (event: string, listener: () => void) => powerListeners.set(event, listener), + off: (event: string) => powerListeners.delete(event) + } +})) + +vi.mock('./worktree-create-preparation', () => ({ + hasPendingWorktreeCreatePreparations: hasPendingPreparationsMock +})) + +vi.mock('./ipc/worktrees/worktree-ipc-context', () => ({ + hasWorktreeRemovalsInFlight: hasRemovalsInFlightMock +})) + +vi.mock('./git/local-repo-ref-maintenance', () => ({ + setRepoMaintenanceActivityProbe: setProbeMock, + disposeLocalRepoRefMaintenance: disposeMock, + postponeRepoRefMaintenance: postponeMock +})) + +import { installRepoMaintenanceIdleGate } from './repo-maintenance-idle-gate' + +function installProbe( + overrides: Partial<{ isQuitting: () => boolean; getWorkingAgentCount: () => number }> = {} +): { probe: () => boolean; uninstall: () => Promise } { + const uninstall = installRepoMaintenanceIdleGate({ + isQuitting: () => false, + getWorkingAgentCount: () => 0, + ...overrides + }) + return { probe: setProbeMock.mock.calls.at(-1)?.[0] as () => boolean, uninstall } +} + +beforeEach(() => { + isOnBatteryPowerMock.mockReturnValue(false) + hasPendingPreparationsMock.mockReturnValue(false) + hasRemovalsInFlightMock.mockReturnValue(false) + postponeMock.mockClear() + powerListeners.clear() + appListeners.clear() + setProbeMock.mockClear() + disposeMock.mockClear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('repo maintenance idle gate', () => { + it('reports idle when nothing is happening', () => { + expect(installProbe().probe()).toBe(false) + }) + + it('vetoes while an agent is working', () => { + expect(installProbe({ getWorkingAgentCount: () => 1 }).probe()).toBe(true) + }) + + it('vetoes while a worktree create is prepared or in flight', () => { + hasPendingPreparationsMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes while a worktree removal is deleting refs', () => { + // Removal deletes branches, and a ref deletion needs the same packed-refs lock. + hasRemovalsInFlightMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes on battery power', () => { + isOnBatteryPowerMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes during shutdown', () => { + expect(installProbe({ isQuitting: () => true }).probe()).toBe(true) + }) + + it('treats an unavailable power API as not-on-battery', () => { + isOnBatteryPowerMock.mockImplementation(() => { + throw new Error('unsupported') + }) + + expect(installProbe().probe()).toBe(false) + }) + + it('pushes the next attempt out when the machine drops onto battery', () => { + // Do-not-start, never stop-what-is-running: killing a pack to honour a + // battery change would strand a ref lock to save a little unlinking. + installProbe() + + powerListeners.get('on-battery')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('pushes the next attempt out when the user comes back to the window', () => { + // A focus transition, not focus itself: a window left focused while the user + // walks away fires no event and blocks nothing. + installProbe() + + appListeners.get('browser-window-focus')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('cancels armed timers, unsubscribes both sources, and clears the probe when uninstalled', async () => { + await installProbe().uninstall() + + expect(disposeMock).toHaveBeenCalledTimes(1) + expect(powerListeners.has('on-battery')).toBe(false) + expect(appListeners.has('browser-window-focus')).toBe(false) + expect(setProbeMock).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts new file mode 100644 index 00000000000..78bb25fe68c --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.ts @@ -0,0 +1,67 @@ +import { app, powerMonitor } from 'electron' +import { + disposeLocalRepoRefMaintenance, + postponeRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './git/local-repo-ref-maintenance' +import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' +import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' + +/** + * The app-wide "not now" answer for idle repo maintenance. + * + * `pack-refs` holds a general git admission slot for its whole run, which on a + * large backlog is minutes, and takes the `packed-refs` lock while it writes. + * Any ref deletion needs that same lock and gives up after + * `core.packedRefsTimeout` (1s), so worktree removal in particular has to veto + * this -- as does a create in flight, an agent mid-run, and shutdown. Battery is + * a veto too: this is work the user did not ask for, and a plugged-in quiet + * window always comes along later. + */ +export type RepoMaintenanceIdleInputs = { + isQuitting: () => boolean + getWorkingAgentCount: () => number +} + +export function installRepoMaintenanceIdleGate( + inputs: RepoMaintenanceIdleInputs +): () => Promise { + setRepoMaintenanceActivityProbe( + () => + inputs.isQuitting() || + inputs.getWorkingAgentCount() > 0 || + hasPendingWorktreeCreatePreparations() || + hasWorktreeRemovalsInFlight() || + isOnBatteryPower() + ) + // Do-not-start, never stop-what-is-running. Killing a pack to honour a battery + // or focus change would strand a ref lock roughly one time in five to save at + // most a couple of minutes of background unlinking; pushing the next attempt + // out costs nothing and risks nothing. + const onBattery = (): void => { + postponeRepoRefMaintenance() + } + const onFocus = (): void => { + postponeRepoRefMaintenance() + } + powerMonitor.on('on-battery', onBattery) + app.on('browser-window-focus', onFocus) + return () => { + app.off('browser-window-focus', onFocus) + powerMonitor.off('on-battery', onBattery) + // Order matters: clearing the probe alone would leave armed timers running + // against a gate that can no longer see agents, creates, or shutdown. + const stopped = disposeLocalRepoRefMaintenance() + setRepoMaintenanceActivityProbe(null) + return stopped + } +} + +function isOnBatteryPower(): boolean { + try { + return powerMonitor.isOnBatteryPower() + } catch { + // Absence of the API is not evidence of battery; desktops answer false anyway. + return false + } +} diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index d0c0ea5c617..d1c3935c1e1 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ +const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ + listWorktreeGraphMock: vi.fn(), listWorktreesMock: vi.fn(), listWorktreesStrictMock: vi.fn() })) vi.mock('./git/worktree', () => ({ + listWorktreeGraph: listWorktreeGraphMock, listWorktrees: listWorktreesMock, listWorktreesStrict: listWorktreesStrictMock })) @@ -14,11 +16,13 @@ import { createFolderWorktree, isRepoRoot, listLocalRepoWorktreesStrict, + listRepoWorktreeGraph, listRepoWorktrees } from './repo-worktrees' describe('repo-worktrees', () => { beforeEach(() => { + listWorktreeGraphMock.mockReset() listWorktreesMock.mockReset() listWorktreesStrictMock.mockReset() }) @@ -109,6 +113,49 @@ describe('repo-worktrees', () => { expect(result).toHaveLength(1) }) + // Path-only callers must reach the probe-free listing, never the annotated one. + it('delegates to the graph listing without sparse annotation', async () => { + listWorktreeGraphMock.mockResolvedValue([ + { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } + ]) + + const result = await listRepoWorktreeGraph({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }) + + expect(listWorktreeGraphMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('returns the synthetic folder worktree from the graph listing', async () => { + const result = await listRepoWorktreeGraph({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + + expect(listWorktreeGraphMock).not.toHaveBeenCalled() + expect(result).toEqual([ + createFolderWorktree({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + ]) + }) + it('delegates strict local listing with the signal and WSL options', async () => { listWorktreesStrictMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 14751c17861..b7c6e16f91a 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,9 +1,10 @@ import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' -import { listWorktrees, listWorktreesStrict } from './git/worktree' +import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree' 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,16 +43,44 @@ 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) : await listWorktrees(repo.path) } +/** + * Worktree rows for callers that read only `worktree.path`. + * + * Skips the sparse-checkout probe behind the badge, which those callers discard. On a WSL repo the + * probe is a 9p stat plus a config read per worktree, re-paid cold after every worktree + * create/remove because that invalidates both the authorized-roots cache and the sparse cache. + */ +export async function listRepoWorktreeGraph( + repo: Repo, + options?: LocalRepoWorktreeListOptions +): Promise { + if (isFolderRepo(repo)) { + return [createFolderWorktree(repo)] + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + return provider ? await provider.listWorktrees(repo.path) : [] + } + return hasLocalRepoWorktreeListOptions(options) + ? await listWorktreeGraph(repo.path, options) + : await listWorktreeGraph(repo.path) +} + export async function listLocalRepoWorktreesStrict( repo: Repo, options?: LocalRepoWorktreeListOptions diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index fc2d761c059..c97966c344e 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -133,9 +133,11 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { const first = runtime.fetchRemoteWithCache('/repo/c', 'origin') const second = runtime.fetchRemoteWithCache('/repo/c', 'origin') - // Allow both callers to register before we resolve. - await Promise.resolve() - await Promise.resolve() + // Allow both callers to register before we resolve. Each canonicalizes the + // repo key first, so the dispatch lands several microtasks in. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } expect(fetchCallCount()).toBe(1) resolveFetch() 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-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-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/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/relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts index 1f2b50e0648..bf9ec2ce431 100644 --- a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts @@ -61,7 +61,8 @@ describe('desktop relay E2EE integration', () => { }) it('splices a simulated phone through CloudRelayTransport with real NaCl E2EE v2', async () => { - const relay = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const relay = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(relay) await new Promise((resolve) => relay.once('listening', resolve)) const address = relay.address() diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index a1ad5c03482..2975b67e631 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -99,7 +99,8 @@ describe('RelayControlClient', () => { }) it('rejects a control handshake that never receives a proof response', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -129,7 +130,7 @@ describe('RelayControlClient', () => { }) it('settles an opening control immediately when ownership closes', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -165,7 +166,7 @@ describe('RelayControlClient', () => { }) it('proves the host key and drives control/data commands without URL credentials', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() 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/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 9236d643e40..3040c37a9ef 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -656,9 +656,21 @@ describe('legacy coordinator takeover races', () => { const detectionStarted = new Promise((resolve) => { signalDetectionStarted = resolve }) + let detectionCalls = 0 vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + detectionCalls += 1 + // Why reject instead of re-arming: a second call would overwrite resolveDetection and + // strand the first promise, hanging to a timeout instead of naming what changed. + if (detectionCalls > 1) { + reject( + new Error( + `isTerminalRunningAgent was called ${detectionCalls} times; this test drives exactly one detection.` + ) + ) + return + } resolveDetection = resolve signalDetectionStarted?.() }) diff --git a/src/main/runtime/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts index 8b7303ed805..21b520c8c9e 100644 --- a/src/main/runtime/rpc/relay-transport.test.ts +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -28,7 +28,8 @@ describe('CloudRelayTransport', () => { }) it('authenticates one query-free host-data socket and forwards messages verbatim', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() 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 528aa318cd4..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 && @@ -185,7 +178,7 @@ export async function createRuntimeLocalGitWorktree(args: { )) ?? {}) let addResult: AddWorktreeResult try { - const preparedResult = + const preparedAttempt = sparseDirectories.length === 0 && !args.checkoutExistingBranch ? await consumePreparedWorktreeCreate({ repoPath: args.repo.path, @@ -197,8 +190,9 @@ export async function createRuntimeLocalGitWorktree(args: { ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) }) : null - if (preparedResult) { - addResult = preparedResult + // This path has no create-span recorder, so the miss reason is only observable on the IPC path. + if (preparedAttempt?.status === 'hit') { + addResult = preparedAttempt.result } else if (sparseDirectories.length > 0) { addResult = (await (addOptions @@ -241,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-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts new file mode 100644 index 00000000000..6df19517d64 --- /dev/null +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -0,0 +1,123 @@ +// Why this file exists: the authoritative missing-metadata prune had exactly one caller, +// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept +// its own repos and their `worktreeMeta` rows grew without bound (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import { testState, createStore, makeRepo } from '../persistence-test-harness' +import type { Store } from '../persistence/loading-store/store' +import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' +import type { RuntimeStore } from './runtime-store-contract' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const gitWorktree = (path: string): GitWorktreeInfo => ({ + path, + branch: 'main', + head: 'abc1234', + isBare: false, + isMainWorktree: true +}) + +function queries( + store: Store, + repo: Repo, + worktrees: readonly GitWorktreeInfo[], + ok = true +): RuntimeManagedWorktreeQueries { + return new RuntimeManagedWorktreeQueries({ + getStore: () => store as unknown as RuntimeStore, + listResolved: async () => [], + resolveRepo: async () => repo, + selectRepos: () => [repo], + scanRepo: async () => ({ ok, worktrees: [...worktrees] }) + }) +} + +describe('runtime detected-worktree listing sweeps missing local metadata', () => { + let repoPath = '' + + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-')) + repoPath = join(testState.dir, 'repo') + mkdirSync(repoPath, { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // Paired with the off-host case below: same fixture, no `connectionId`. + it('drops a metadata row whose directory is gone and the scan does not list', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + expect(store.getWorktreeMeta(missingId)).toBeDefined() + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeUndefined() + }) + + it('keeps a row whose directory still exists', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const livePath = join(testState.dir, 'live-worktree') + mkdirSync(livePath, { recursive: true }) + const liveId = `${repo.id}::${livePath}` + store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(liveId)).toBeDefined() + }) + + // A non-authoritative scan is a failed listing, which is no evidence any checkout is gone. + it('keeps every row when the scan is not authoritative', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [], false).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) + + // The execution host owns this verdict: this host cannot stat a checkout that lives behind an SSH + // connection, so a local miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md. + // + // Deliberately identical to the first case except for `connectionId`, and the row is stamped + // `local` so it is a real prune candidate. That pairing is the proof: the same fixture without a + // connection loses the row, so the connection is the only reason this one keeps it. Removing any + // single gate would not show that -- four independent checks derive from `connectionId` here. + it('never sweeps a repo whose git runs off-host', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index d444f024e84..b0ed2bc4a3b 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -20,6 +20,10 @@ import { } from '../../shared/worktree/visibility-sources' import { mergeWorktree } from '../ipc/worktree-logic' import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { Store } from '../persistence' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' @@ -36,6 +40,31 @@ type Dependencies = { scanRepo(repo: Repo): Promise } +/** + * The destructive scan expectation for one repo, or undefined when this repo must not carry one. + * + * WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing + * runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove + * those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which + * is likewise no basis for deleting rows. + */ +function captureLocalMetadataPruneExpectation( + store: RuntimeStore, + repo: Repo +): NativeLocalWorktreeMetadataScanExpectation | undefined { + if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') { + return undefined + } + try { + if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) { + return undefined + } + } catch { + return undefined + } + return store.captureNativeLocalWorktreeMetadataScanExpectation(repo) +} + export class RuntimeManagedWorktreeQueries { constructor(private readonly deps: Dependencies) {} @@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries { worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } + // Why capture before the scan: listing can mutate metadata synchronously before its first + // await, and the prune revalidates against the rows as they stood when the scan was issued. + const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id) + const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo) let scan: RuntimeWorktreeScanResult try { scan = await this.deps.scanRepo(repo) @@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries { scan = { ok: false, worktrees: [] } } if (scan.ok) { + // Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`, + // so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776). + if (metadataPruneExpectation) { + await pruneMetadataMissingFromAuthoritativeLocalScan({ + store: store as unknown as Store, + repo, + gitWorktrees: scan.worktrees, + scan: metadataPruneExpectation, + scanGeneration: metadataScanGeneration + }) + } pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees) } const matcher = createWorktreeVisibilitySourceMatcher( 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-remote-fetch-controller.ts b/src/main/runtime/runtime-remote-fetch-controller.ts index c48a8437a3d..b3b9df5dcba 100644 --- a/src/main/runtime/runtime-remote-fetch-controller.ts +++ b/src/main/runtime/runtime-remote-fetch-controller.ts @@ -1,4 +1,9 @@ import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' +import { getCanonicalRepoKey } from '../git/canonical-repo-key' +import { + armLocalRepoRefMaintenance, + setRepoRefMaintenanceBusyProbe +} from '../git/local-repo-ref-maintenance' import { gitExecFileAsync } from '../git/runner' import { setBoundedMapEntry } from './runtime-async-boundaries' @@ -33,6 +38,11 @@ export class RuntimeRemoteFetchController { return this.fetchLastCompletedAt } + /** `${runtimeKey}::${gitCommonDir}` -- one repo on one execution host. */ + async getCanonicalRepoKey(repoPath: string, gitOptions: GitOptions = {}): Promise { + return getCanonicalRepoKey(repoPath, gitOptions) + } + async getCanonicalFetchKey( repoPath: string, remote: string, @@ -45,23 +55,41 @@ export class RuntimeRemoteFetchController { setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) return cached } - let resolved = cacheKey - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--git-common-dir'], - { cwd: repoPath, ...gitOptions } - ) - const commonDir = stdout.trim() - if (commonDir) { - resolved = `${runtimeKey}::${commonDir}::${remote}` - } - } catch { - // The caller path remains a safe serialization key when canonicalization fails. - } + const resolved = `${await this.getCanonicalRepoKey(repoPath, gitOptions)}::${remote}` setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) return resolved } + /** + * Orca strips git's auto-maintenance off these fetches, so every one of them + * adds to a loose-ref backlog nothing else will ever pack. Arm the idle sweep + * that pays it back; each fetch pushes the attempt a further quiet period out. + */ + private armRefMaintenance(repoPath: string, gitOptions: GitOptions): void { + void this.getCanonicalRepoKey(repoPath, gitOptions) + .then((key) => { + setRepoRefMaintenanceBusyProbe(key, () => this.hasInflightFetchForRepo(key)) + armLocalRepoRefMaintenance({ + key, + repoPath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }) + }) + .catch(() => { + // Maintenance is best effort; a repo we cannot name is a repo we skip. + }) + } + + private hasInflightFetchForRepo(repoKey: string): boolean { + const prefix = `${repoKey}::` + for (const key of this.fetchInflight.keys()) { + if (key.startsWith(prefix)) { + return true + } + } + return false + } + private enqueueRemoteFetch( remoteKey: string, runFetch: () => Promise @@ -123,6 +151,7 @@ export class RuntimeRemoteFetchController { }) ).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise @@ -178,6 +207,7 @@ export class RuntimeRemoteFetchController { }) }).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise diff --git a/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts new file mode 100644 index 00000000000..f577da24854 --- /dev/null +++ b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: Orca's fetches are what create the loose-ref backlog (they suppress +// git's auto-maintenance), so the fetch controller is where the idle sweep has +// to be armed. These tests pin that wiring and the per-repo busy signal it +// hands the sweep. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const armMock = vi.hoisted(() => vi.fn()) +const busyProbeMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../git/local-repo-ref-maintenance', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + armLocalRepoRefMaintenance: armMock, + setRepoRefMaintenanceBusyProbe: busyProbeMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from '../git/canonical-repo-key' +import { RuntimeRemoteFetchController } from './runtime-remote-fetch-controller' + +function armedTargets(): { key: string }[] { + return armMock.mock.calls.map(([args]) => args as { key: string }) +} + +/** The per-repo "a fetch is in flight" answer the controller registers for a key. */ +function busyProbeFor(key: string): (() => boolean) | undefined { + return busyProbeMock.mock.calls.findLast(([registered]) => registered === key)?.[1] as + | (() => boolean) + | undefined +} + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() + armMock.mockReset() + busyProbeMock.mockReset() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' ? { stdout: '/repo/.git\n', stderr: '' } : { stdout: '', stderr: '' } + ) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('fetch-armed ref maintenance', () => { + it('arms the sweep for the repo after a remote fetch, keyed by common dir', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + + expect(armedTargets().map((target) => target.key)).toEqual(['local::/repo/.git']) + }) + + it('gives every worktree of one repo the same maintenance key', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + await controller.getOrStartRemoteTrackingBaseRefresh('/repo/worktrees/b', { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + + const keys = new Set(armedTargets().map((target) => target.key)) + expect(keys).toEqual(new Set(['local::/repo/.git'])) + }) + + it('scopes the key to the WSL distro that executes the repo', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('//wsl$/Ubuntu/repo', 'origin', { + wslDistro: 'Ubuntu' + }) + + expect(armedTargets()[0]?.key).toBe('wsl:Ubuntu::/repo/.git') + }) + + it('does not collapse every repo onto one key on Git older than 2.31', async () => { + // Old Git echoes the unrecognized `--path-format` flag, exits 0, and prints a + // relative `.git`; taking that raw would name every repository identically. + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' + ? { stdout: '--path-format=absolute\n.git\n', stderr: '' } + : { stdout: '', stderr: '' } + ) + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/one', 'origin') + await controller.getOrStartRemoteFetch('/repo/two', 'origin') + + expect(armedTargets().map((entry) => entry.key)).toEqual([ + 'local::/repo/one/.git', + 'local::/repo/two/.git' + ]) + }) + + it('reports the repo as busy while another fetch on it is in flight', async () => { + const controller = new RuntimeRemoteFetchController() + await controller.getOrStartRemoteFetch('/repo', 'first') + const isBusy = busyProbeFor('local::/repo/.git') + expect(isBusy?.()).toBe(false) + + let releaseFetch: (() => void) | undefined + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + await new Promise((resolve) => { + releaseFetch = resolve + }) + return { stdout: '', stderr: '' } + }) + const second = controller.getOrStartRemoteFetch('/repo', 'second') + await vi.waitFor(() => expect(releaseFetch).toBeDefined()) + expect(isBusy?.()).toBe(true) + + releaseFetch?.() + await second + expect(isBusy?.()).toBe(false) + }) + + it('arms even when the fetch fails, because a partial fetch still writes refs', async () => { + const controller = new RuntimeRemoteFetchController() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + throw new Error('network is unreachable') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(controller.getOrStartRemoteFetch('/repo', 'origin')).resolves.toEqual({ + ok: false, + errorKind: 'git_error' + }) + expect(armedTargets()).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 692826b3c29..e160e039533 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -30,6 +30,9 @@ export type RuntimeStore = { removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] + captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation'] + pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo'] + getProfileStorageDirectory?: Store['getProfileStorageDirectory'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] 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/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-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 51cbdb26794..96362ffbfc2 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness' +import { + testState, + createStore, + makeRepo, + makeTerminalTab, + writeDataFile +} from './persistence-test-harness' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' import { getDefaultPersistedState } from '../shared/constants' @@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('does not clear and rebind a retired surface loaded from an older profile', async () => { const paneKey = `${TAB}:${TEST_LEAF_1}` const persisted = getDefaultPersistedState(testState.dir) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] persisted.workspaceSession = { ...persisted.workspaceSession, ...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }), 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/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 3f364a9335c..89be84d2119 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' import { logStartupMilestone } from './startup-diagnostics' import { mainProcessState as state } from './main-process-state' +import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' export function registerMainProcessIpcHandlers(): void { ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { @@ -36,6 +37,20 @@ export function registerMainProcessIpcHandlers(): void { state.pendingOpenSettings.matches(event.sender.id, { consume: true }) ) ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + // Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a + // cold-start "Open With" queued before mount still opens. The pull doubles as the proof + // that the listener is live, which is what lets main start pushing. + ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => { + state.markdownFileOpenListenerReady = true + const filePaths = state.osOpenedMarkdownFiles.consume() + try { + return await resolveOpenedMarkdownDocuments(filePaths) + } catch (error) { + // Why restored: the renderer never received these, so a later mount must still get them. + state.osOpenedMarkdownFiles.restore(filePaths) + throw error + } + }) ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index c3d83450b7c..ba37b0a312f 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -18,6 +18,7 @@ import { AgentSessionTransitionRecorder } from '../stats/agent-session-transitio import { ClaudeUsageStore } from '../claude-usage/store' import { CodexUsageStore } from '../codex-usage/store' import { OpenCodeUsageStore } from '../opencode-usage/store' +import { installRepoMaintenanceIdleGate } from '../repo-maintenance-idle-gate' import { mainProcessState as state } from './main-process-state' export function initializeMainProcessObservers(): void { @@ -35,6 +36,10 @@ export function initializeMainProcessObservers(): void { ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. state.agentAwakeService.setStatuses([]) + state.uninstallRepoMaintenanceIdleGate = installRepoMaintenanceIdleGate({ + isQuitting: () => state.isQuitting, + getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 + }) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { const ownedIdentities = identities.map((identity) => ({ 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 ec893456c5a..61203bc3164 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -14,6 +14,7 @@ import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' import { browserManager } from '../browser/browser-manager' import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' import { quitTeardownStartGate } from '../quit-teardown-start-gate' import { setUnreadDockBadgeCount } from '../dock/unread-badge' @@ -33,6 +34,8 @@ let daemonDisconnectDone = false let watcherShutdownPromise: Promise | null = null // Why 2s: a config delete is best-effort, not durable state. const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 +// Why 2s: long enough for a `pack-refs` child to take SIGTERM and unlink its lock. +const REF_MAINTENANCE_QUIT_DEADLINE_MS = 2_000 function shutdownWatchersOnce(): Promise { if (state.watcherShutdownDone) { @@ -73,6 +76,10 @@ function installBeforeQuitHandler(): void { state.unsubscribeAgentAwakeStatusChanges = null state.agentAwakeService?.dispose() state.agentAwakeService = null + // Why wait but not uninstall: a renderer beforeunload can still veto this + // quit, and tearing the sweep down here would kill it for the rest of the + // session. `isQuitting` already vetoes new attempts; will-quit does the teardown. + 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() }) @@ -123,6 +130,16 @@ function installWillQuitHandler(): void { const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() state.pluginService = null setUnreadDockBadgeCount(0) + // Why wait rather than kill: the child finishes fine orphaned, and signalling + // it mid-prune strands a ref lock Git never clears. The wait is only for the + // short rewrite window, and is bounded so a quit can never hang on it. + const refMaintenanceShutdown = settleWithinMs( + Promise.all([state.repoMaintenanceShutdown, state.uninstallRepoMaintenanceIdleGate?.()]).then( + () => {} + ), + REF_MAINTENANCE_QUIT_DEADLINE_MS + ).then(() => {}) + state.uninstallRepoMaintenanceIdleGate = null agentHookServer.stop() // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. @@ -219,6 +236,7 @@ function installWillQuitHandler(): void { { name: 'plugin-hosts', promise: pluginHostShutdown }, { name: 'skill-uploads', promise: skillUploadShutdown }, { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'ref-maintenance', promise: refMaintenanceShutdown }, { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 78061fb439c..7e5f278fdc5 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) 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-process-state.ts b/src/main/startup/main-process-state.ts index ea1e0a33299..d48219b461e 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -36,6 +36,7 @@ import type { ServeOptions } from './main-process-serve' import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' import { ServeReadinessPublisher } from '../server/serve-readiness' import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -70,6 +71,8 @@ export const mainProcessState = { headlessBrowserDisplayAvailable: false, starNag: null as StarNagService | null, agentAwakeService: null as AgentAwakeService | null, + uninstallRepoMaintenanceIdleGate: null as (() => Promise) | null, + repoMaintenanceShutdown: Promise.resolve() as Promise, crashReports: null as CrashReportStore | null, unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, publishProviderSessionChanges: null as @@ -90,6 +93,12 @@ export const mainProcessState = { // Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. pendingOpenSettings: createWebContentsTimedFlag(), skillShareDeepLinks: new SkillShareDeepLinkState(), + // Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount. + osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(), + // Why a latch and not just "a window exists": a window can be up while its renderer has not + // attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by + // Electron with no error. Only the renderer's own pull proves the listener is live. + markdownFileOpenListenerReady: false, firstWindowStartupServicesReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), 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/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 57763b23ab1..0d935d5f84a 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -145,6 +145,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} clearExpectedRendererReload(rendererWebContentsId) recordCrashBreadcrumb('main_window_loaded') logStartupMilestone('did-finish-load') + // Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh + // renderer re-attaches by pulling. Pushing into the gap between would be silently lost. + state.markdownFileOpenListenerReady = false const currentStore = state.store if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { trackAppOpenedOnce() diff --git a/src/main/startup/os-opened-markdown-delivery.test.ts b/src/main/startup/os-opened-markdown-delivery.test.ts new file mode 100644 index 00000000000..0647516e3fa --- /dev/null +++ b/src/main/startup/os-opened-markdown-delivery.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' + +/** + * The two ways a queued "Open With" can be lost between main and the renderer. Both are + * about ownership: main must not drop paths it has not proven the renderer received. + */ +describe('os-opened markdown delivery ownership', () => { + it('keeps the batch when resolution rejects on the pull path', async () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable')) + + // Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body. + const pull = async (): Promise => { + const filePaths = state.consume() + try { + return await resolve(filePaths) + } catch (error) { + state.restore(filePaths) + throw error + } + } + + await expect(pull()).rejects.toThrow('floating root unavailable') + // Without the restore the file would be gone and no later mount could ever open it. + expect(state.consume()).toEqual(['/notes/a.md']) + }) + + it('holds the batch while the renderer listener is not yet attached', () => { + const state = new OsOpenedMarkdownFileState() + const send = vi.fn() + let listenerReady = false + + // Mirrors publishOsOpenedMarkdownFiles()'s guard. + const publish = (): void => { + if (!listenerReady) { + return + } + const filePaths = state.consume() + if (filePaths.length > 0) { + send(filePaths) + } + } + + // A window exists, but the renderer has not mounted its bridge yet: send() here would be + // dropped by Electron with no error, and consuming would destroy the queue. + state.captureFilePaths(['/notes/a.md'], publish) + expect(send).not.toHaveBeenCalled() + + // The renderer's pull is what proves the listener is live. + listenerReady = true + state.captureFilePaths(['/notes/b.md'], publish) + expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md']) + expect(state.consume()).toEqual([]) + }) + + it('restores a batch the window could no longer receive', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const filePaths = state.consume() + + // Window died between consume and send. + state.restore(filePaths) + + expect(state.consume()).toEqual(['/notes/a.md']) + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.test.ts b/src/main/startup/os-opened-markdown-files.test.ts new file mode 100644 index 00000000000..f174e10d834 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.test.ts @@ -0,0 +1,306 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isMarkdownDocumentName } from '../ipc/markdown-documents' +import { + MAX_PENDING_OS_OPENED_MARKDOWN_FILES, + OsOpenedMarkdownFileState, + markdownPathsFromArguments, + resolveOpenedMarkdownDocuments +} from './os-opened-markdown-files' + +vi.mock('../ipc/filesystem-auth', () => ({ + authorizeExternalPath: vi.fn() +})) +vi.mock('../ipc/floating-workspace-directory', () => ({ + ensureDefaultFloatingWorkspacePath: vi.fn() +})) + +const { authorizeExternalPath } = await import('../ipc/filesystem-auth') +const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory') + +describe('markdownPathsFromArguments', () => { + it('keeps absolute markdown paths and drops other extensions', () => { + expect( + markdownPathsFromArguments( + [ + '/Users/dev/notes/a.md', + '/Users/dev/notes/b.markdown', + '/Users/dev/notes/c.mdx', + '/Users/dev/notes/d.txt', + '/Users/dev/src/e.tsx', + '/Users/dev/notes/README' + ], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx']) + }) + + it('drops switches, including Chromium-style ones that would otherwise look like values', () => { + expect( + markdownPathsFromArguments( + ['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops the executable and dev entries because none of them end in a markdown extension', () => { + const nonDocumentEntries = [ + '/Applications/Orca.app/Contents/MacOS/Orca', + '/Users/dev/orca/out/main/index.js', + '/Applications/Orca.app/Contents/Resources/app.asar' + ] + // The module documents that the extension check alone excludes these; hold it to that. + for (const entry of nonDocumentEntries) { + expect(isMarkdownDocumentName(entry), entry).toBe(false) + } + expect( + markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin') + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops relative paths because a second instance has no meaningful cwd', () => { + expect( + markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin') + ).toEqual([]) + }) + + it('accepts win32 drive-letter and UNC paths', () => { + expect( + markdownPathsFromArguments( + ['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'], + 'win32' + ) + ).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes case-insensitively on win32 and keeps the first spelling', () => { + expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([ + 'C:\\notes\\A.md' + ]) + }) + + it('normalizes parent segments before deduping', () => { + expect( + markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32') + ).toEqual(['C:\\notes\\a.md']) + expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([ + '/notes/a.md' + ]) + }) + + it('does not dedupe case-insensitively on posix, where casing is a different file', () => { + expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([ + '/a/A.md', + '/a/a.md' + ]) + }) + + it('accepts a file:// URI, which the desktop entry %U field code permits', () => { + // Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain + // path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch + // today. The %U spec still allows a URI, and a launcher that passes one literally would + // otherwise be dropped without a trace. + expect( + markdownPathsFromArguments( + ['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'], + 'linux' + ) + ).toEqual(['/home/me/notes/a.md']) + }) + + it('percent-decodes a file:// URI so a path with spaces still opens', () => { + expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([ + '/home/me/design notes.md' + ]) + }) + + it('decodes win32 file:// URIs, including UNC authority form', () => { + expect( + markdownPathsFromArguments( + ['file:///C:/Users/me/todo.md', 'file://server/share/a.md'], + 'win32' + ) + ).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes a path delivered as both a URI and a bare path', () => { + expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([ + '/home/me/a.md' + ]) + }) + + it('drops a malformed or non-file URL instead of throwing', () => { + expect(() => + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).not.toThrow() + expect( + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).toEqual([]) + }) + + it('honours the platform argument rather than the host OS', () => { + const argv = ['C:\\notes\\a.md', '/notes/b.md'] + // Same argv, two platforms: a win32 path is not absolute to posix, and posix input is + // renormalized to backslashes on win32. Neither result may depend on where the suite runs. + expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md']) + expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md']) + }) +}) + +// Why resolve(): the state uses the host platform by default, so fixture paths must already be +// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows). +const hostPath = (name: string): string => resolve(sep, 'notes', name) + +describe('OsOpenedMarkdownFileState', () => { + it('reports no capture and does not publish when argv carries no markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe( + false + ) + expect(publish).not.toHaveBeenCalled() + expect(state.consume()).toEqual([]) + }) + + it('buffers and publishes when argv carries markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe( + true + ) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('captures a single macOS open-file path', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.captureFilePaths([filePath], publish)).toBe(true) + expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('does not duplicate a path captured twice', () => { + const state = new OsOpenedMarkdownFileState() + const filePath = hostPath('a.md') + + state.captureFilePaths([filePath]) + state.captureFilePaths([filePath]) + state.capture(['orca', filePath]) + + expect(state.consume()).toEqual([filePath]) + }) + + it('drains the buffer on consume', () => { + const state = new OsOpenedMarkdownFileState() + const paths = [hostPath('a.md'), hostPath('b.md')] + state.captureFilePaths(paths) + + expect(state.consume()).toEqual(paths) + expect(state.consume()).toEqual([]) + }) + + it('restores an undelivered batch at the front of the buffer', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('later.md')]) + + state.restore([hostPath('undelivered.md')]) + + expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')]) + }) + + it('caps the buffer when captures overflow it', () => { + const state = new OsOpenedMarkdownFileState() + const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5 + const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`)) + + expect(state.captureFilePaths(paths)).toBe(true) + + expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)) + }) + + it('caps the buffer when a restore overflows it', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('pending.md')]) + const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) => + hostPath(`restored-${index}.md`) + ) + + state.restore(restored) + + const pending = state.consume() + expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + expect(pending).toEqual(restored) + }) +}) + +describe('resolveOpenedMarkdownDocuments', () => { + let floatingRoot: string + let fileRoot: string + + beforeEach(async () => { + vi.mocked(authorizeExternalPath).mockClear() + vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear() + floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-')) + fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-')) + vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot) + }) + + afterEach(async () => { + await rm(floatingRoot, { recursive: true, force: true }) + await rm(fileRoot, { recursive: true, force: true }) + }) + + it('resolves a real file outside the floating root to a basename-relative document', async () => { + const filePath = join(fileRoot, 'design notes.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([filePath]) + + expect(documents).toEqual([ + { + filePath, + relativePath: 'design notes.md', + basename: 'design notes.md', + name: 'design notes' + } + ]) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a directory that merely looks like a markdown file', async () => { + const bundlePath = join(fileRoot, 'bundle.md') + await mkdir(bundlePath) + const filePath = join(fileRoot, 'real.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath]) + + expect(documents.map((document) => document.filePath)).toEqual([filePath]) + // Security contract: a path we never validated must never be authorized for renderer reads. + expect(authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a path that no longer exists without authorizing it', async () => { + const missingPath = join(fileRoot, 'gone.md') + + expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([]) + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) + + it('returns nothing for an empty input without touching the filesystem', async () => { + expect(await resolveOpenedMarkdownDocuments([])).toEqual([]) + expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.ts b/src/main/startup/os-opened-markdown-files.ts new file mode 100644 index 00000000000..dae27fb7a78 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.ts @@ -0,0 +1,149 @@ +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { authorizeExternalPath } from '../ipc/filesystem-auth' +import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' +import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents' + +// Why: a shell can only ever hand over the files the user selected; anything past this is a +// runaway argv, and buffering it unbounded would pin the paths for the whole session. +export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32 + +/** + * Resolves one argv entry to a local absolute path, or null if it is not one. + * + * Why file:// is accepted defensively: electron-builder appends the `%U` field code to the + * generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a + * local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via + * the same `launch_uris` call a file manager makes), so the branch below is not what fires + * there today — but the spec permits a URI, and a launcher that honours it literally would + * otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths. + */ +function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null { + const pathApi = platform === 'win32' ? path.win32 : path.posix + if (argument.startsWith('file://')) { + try { + // Why the explicit windows flag: this must decode the same way on any host so the + // behaviour is testable, and it is what turns `file://server/share` back into a UNC path. + return fileURLToPath(argument, { windows: platform === 'win32' }) + } catch { + return null + } + } + return pathApi.isAbsolute(argument) ? argument : null +} + +/** + * Absolute markdown paths an OS "Open With" put on a launch or second-instance argv. + * + * Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown + * extension, so the extension check already excludes them. Relative entries are dropped + * because the shell always passes absolute paths and `cwd` is meaningless for a second instance. + */ +export function markdownPathsFromArguments( + argv: readonly string[], + platform: NodeJS.Platform = process.platform +): string[] { + const pathApi = platform === 'win32' ? path.win32 : path.posix + const seen = new Set() + const paths: string[] = [] + for (const rawArgument of argv) { + if (!rawArgument || rawArgument.startsWith('-')) { + continue + } + const argument = localPathFromArgument(rawArgument, platform) + if (!argument || !isMarkdownDocumentName(argument)) { + continue + } + const normalized = pathApi.normalize(argument) + // Why lowercased on win32: the shell round-trips drive letters and 8.3 casing + // inconsistently, and two spellings of one path must not open two tabs. + const key = platform === 'win32' ? normalized.toLowerCase() : normalized + if (seen.has(key)) { + continue + } + seen.add(key) + paths.push(normalized) + } + return paths +} + +/** + * Buffers markdown paths the OS handed us until a renderer can receive them. + * + * Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the + * renderer pulls the same buffer when its listener attaches, so a cold-start "Open With" + * that lands before mount is not dropped. + */ +export class OsOpenedMarkdownFileState { + private pending: string[] = [] + + /** Returns true when argv carried at least one markdown path. */ + capture(argv: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(argv), publish) + } + + /** Returns true when at least one path was a markdown document. */ + captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(filePaths), publish) + } + + consume(): string[] { + const pending = this.pending + this.pending = [] + return pending + } + + /** Puts an undelivered batch back at the front so the next renderer still receives it. */ + restore(filePaths: readonly string[]): void { + this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + } + + private add(filePaths: readonly string[], publish?: () => void): boolean { + if (filePaths.length === 0) { + return false + } + const merged = [...this.pending] + for (const filePath of filePaths) { + if (!merged.includes(filePath)) { + merged.push(filePath) + } + } + this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + publish?.() + return true + } +} + +/** + * Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own + * file picker produces, authorizing each one for the renderer's later read. + */ +export async function resolveOpenedMarkdownDocuments( + filePaths: readonly string[] +): Promise { + if (filePaths.length === 0) { + return [] + } + const floatingRoot = await ensureDefaultFloatingWorkspacePath() + const documents: MarkdownDocument[] = [] + for (const filePath of filePaths) { + try { + // Why: the shell can hand over a bundle directory named `*.md`, or a path already + // deleted by the time we resolve. Authorize only something that is really a file. + if (!(await stat(filePath)).isFile()) { + continue + } + } catch { + continue + } + authorizeExternalPath(filePath) + documents.push( + markdownDocumentFromFilePath(floatingRoot, filePath, { + outsideRootRelativePath: 'basename' + }) + ) + } + return documents +} diff --git a/src/main/startup/os-opened-markdown-wiring.test.ts b/src/main/startup/os-opened-markdown-wiring.test.ts new file mode 100644 index 00000000000..179ca0820ca --- /dev/null +++ b/src/main/startup/os-opened-markdown-wiring.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const read = (relativePath: string): string => + // Why source text: this wiring is module-scope side effects in the entry point, which no + // unit test can import without booting Electron. These guards pin the call shapes instead. + readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'") + +describe('os-opened markdown wiring', () => { + const index = read('src/main/index.ts') + const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts') + const controller = read('src/main/startup/main-window-controller.ts') + + it('captures argv before the serve-duplicate early return', () => { + const captureIndex = index.indexOf( + 'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)' + ) + const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {') + + expect(captureIndex).toBeGreaterThanOrEqual(0) + expect(serveGuardIndex).toBeGreaterThanOrEqual(0) + // A duplicate `orca serve` returns early; capturing after that would drop the user's files. + expect(captureIndex).toBeLessThan(serveGuardIndex) + }) + + it('claims the macOS open-file event so the default handler does not win it', () => { + const handlerIndex = index.indexOf("app.on('open-file'") + expect(handlerIndex).toBeGreaterThanOrEqual(0) + + const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex) + const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1) + expect(preventDefaultIndex).toBeGreaterThan(handlerIndex) + if (nextRegistrationIndex !== -1) { + expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex) + } + }) + + it('captures the cold-start argv and lets the renderer pull it after mount', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)') + expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'") + }) + + // Why: `webContents.send` to a renderer that has not attached the listener is dropped with no + // error, so publishing on "a window exists" alone would consume the queue into a void. + it('only pushes once the renderer has proven its listener is attached', () => { + expect(index).toContain('!state.markdownFileOpenListenerReady') + expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true') + // A reload drops the listener; the fresh renderer re-proves itself by pulling again. + expect(controller).toContain('state.markdownFileOpenListenerReady = false') + }) + + it('restores an undelivered batch on both the push and the pull path', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + }) +}) 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/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 2146563373d..73d1bb21cdc 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -105,6 +105,56 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env, ...extra } } +/** + * What the two cases below wait on: a ~280MB clone of Electron.app, two swiftc + * helper builds, and `codesign --deep` over the result. Six seconds on an idle + * machine; the swiftc builds alone pass fifteen when this file runs inside the + * full suite and every core is taken. The generous ceiling only costs time on a + * run that is already failing. + */ +const PREPARE_TIMEOUT_MS = 90_000 + +/** + * Spawns the wrapper with its output retained. + * + * Why retained: the wrapper reports its own failures on stderr, and discarding + * them turned a crash in prepare into a bare "Timed out waiting for condition" + * with nothing to act on. + */ +function spawnDevWrapper( + args: string[], + env: NodeJS.ProcessEnv +): { wrapper: ChildProcess; readOutput: () => string } { + const wrapper = spawn(process.execPath, args, { + cwd: resolve('.'), + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let output = '' + const collect = (chunk: Buffer): void => { + output += chunk.toString() + } + wrapper.stdout?.on('data', collect) + wrapper.stderr?.on('data', collect) + return { wrapper, readOutput: () => output } +} + +async function waitForEnvFile(envFile: string, readOutput: () => string): Promise { + try { + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }, PREPARE_TIMEOUT_MS) + } catch (error) { + throw new Error( + `${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}` + ) + } +} + describe('run-electron-vite-dev', () => { afterEach(async () => { for (const pid of processesToCleanUp) { @@ -351,26 +401,19 @@ describe('run-electron-vite-dev', () => { async function runWrapper(runId: string): Promise<{ electronExecPath: string }> { const pidFile = join(tempDir, `${runId}.pid`) const envFile = join(tempDir, `${runId}.json`) - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: { + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + { ...baseEnv, ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile - }, - stdio: 'ignore' - }) + } + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -409,7 +452,8 @@ describe('run-electron-vite-dev', () => { } } }, - 30000 + // Two full prepares, each budgeted at PREPARE_TIMEOUT_MS. + PREPARE_TIMEOUT_MS * 2 + 30_000 ) it.skipIf(process.platform !== 'darwin')( @@ -421,9 +465,9 @@ describe('run-electron-vite-dev', () => { const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: devWrapperTestEnv({ + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + devWrapperTestEnv({ ORCA_ELECTRON_VITE_CLI: fakeCliPath, ORCA_SKIP_DEV_CLI_PREPARE: '1', ORCA_SKIP_DEV_WEB_PREPARE: '1', @@ -431,20 +475,13 @@ describe('run-electron-vite-dev', () => { ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, ORCA_DEV_BRANCH: 'feature/framework-symlinks', ORCA_DEV_WORKTREE_NAME: 'symlink-ui' - }), - stdio: 'ignore' - }) + }) + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -464,6 +501,6 @@ describe('run-electron-vite-dev', () => { await stopWrapperAndTrackedPids(wrapper, trackedPids) }, - 30000 + PREPARE_TIMEOUT_MS + 30_000 ) }) 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/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/worktree-create-preparation-burst.ts b/src/main/worktree-create-preparation-burst.ts new file mode 100644 index 00000000000..d289927ffaa --- /dev/null +++ b/src/main/worktree-create-preparation-burst.ts @@ -0,0 +1,21 @@ +import { setBoundedMapEntry } from './runtime/runtime-async-boundaries' + +/** Two creates this close together mean more are likely; an isolated create earns no replacement. */ +export const WORKTREE_CREATE_BURST_MS = 5 * 60_000 +const WORKTREE_CREATE_PREPARATION_CONSUME_MAX = 64 + +/** When each preparation key was last consumed, so a burst can be told from an isolated create. */ +const lastConsumedAt = new Map() + +/** Records this consume and reports whether it continues a burst. A replacement checkout costs a + * full tree and holds disk until its TTL, so only a user who is already creating repeatedly earns + * one; the first create of a session pays nothing for a spare nobody claims. */ +export function recordPreparationConsume(key: string, now = Date.now()): boolean { + const previous = lastConsumedAt.get(key) + setBoundedMapEntry(lastConsumedAt, key, now, WORKTREE_CREATE_PREPARATION_CONSUME_MAX) + return previous !== undefined && now - previous <= WORKTREE_CREATE_BURST_MS +} + +export function resetPreparationConsumeHistoryForTests(): void { + lastConsumedAt.clear() +} diff --git a/src/main/worktree-create-preparation-claim.test.ts b/src/main/worktree-create-preparation-claim.test.ts new file mode 100644 index 00000000000..8abc42f30fe --- /dev/null +++ b/src/main/worktree-create-preparation-claim.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { + preparationPathKey, + selectPreparationForCreate, + type PreparationCandidate, + type PreparationRequest +} from './worktree-create-preparation-claim' + +function candidate(overrides: Partial = {}): PreparationCandidate { + return { + repoPathKey: '/repo', + workspaceRootKey: '/workspace', + wslDistro: '', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + createdAt: 1_000, + ...overrides + } +} + +function request(overrides: Partial = {}): PreparationRequest { + return { + repoPathKey: '/repo', + workspaceRootKey: '/workspace', + wslDistro: '', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + ...overrides + } +} + +describe('selectPreparationForCreate', () => { + it('matches the identical base before any ref probe has run', () => { + const selection = selectPreparationForCreate([candidate()], request({ canonicalBase: null })) + + expect(selection).toEqual({ + kind: 'exact', + candidate: candidate(), + canonicalBase: 'refs/remotes/origin/main' + }) + }) + + it('asks for a canonical base only when something is armed under another spelling', () => { + expect( + selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'main', canonicalBase: null }) + ) + ).toEqual({ kind: 'needs-canonical-base' }) + // Nothing armed for this repo, so the create must not pay a probe to learn that. + expect( + selectPreparationForCreate([], request({ baseBranch: 'main', canonicalBase: null })) + ).toEqual({ kind: 'miss', reason: 'none_armed' }) + }) + + it('matches when the two sides spell the same ref differently', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'refs/remotes/origin/main' }) + ) + + expect(selection).toEqual({ + kind: 'exact', + candidate: candidate(), + canonicalBase: 'refs/remotes/origin/main' + }) + }) + + it('retargets a local base onto the armed remote-tracking base of the same branch', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toEqual({ + kind: 'retarget', + candidate: candidate(), + canonicalBase: 'refs/heads/main' + }) + }) + + it('prefers the freshest armed entry when several share the family', () => { + const older = candidate({ canonicalBase: 'refs/remotes/origin/main', createdAt: 1 }) + const newer = candidate({ canonicalBase: 'refs/remotes/upstream/main', createdAt: 2 }) + + const selection = selectPreparationForCreate( + [older, newer], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toMatchObject({ kind: 'retarget', candidate: newer }) + }) + + it('refuses to retarget onto a different branch', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'origin/release', canonicalBase: 'refs/remotes/origin/release' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' }) + }) + + it('refuses to retarget onto a bare commit id, whose divergence is unbounded', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: '1f2e3d4c5b6a7988', canonicalBase: '1f2e3d4c5b6a7988' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' }) + }) + + it('names the key field that disagreed', () => { + expect(selectPreparationForCreate([], request())).toEqual({ + kind: 'miss', + reason: 'none_armed' + }) + // Something is warm, just not for this repo — the shape of a size-cap eviction. + expect( + selectPreparationForCreate([candidate()], request({ repoPathKey: '/other-repo' })) + ).toEqual({ kind: 'miss', reason: 'repo_mismatch' }) + expect(selectPreparationForCreate([candidate()], request({ wslDistro: 'Ubuntu' }))).toEqual({ + kind: 'miss', + reason: 'wsl_distro_mismatch' + }) + expect( + selectPreparationForCreate([candidate()], request({ workspaceRootKey: '/other' })) + ).toEqual({ kind: 'miss', reason: 'workspace_root_mismatch' }) + }) + + it('never crosses hosts to satisfy a family retarget', () => { + const selection = selectPreparationForCreate( + [candidate({ wslDistro: 'Ubuntu' })], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'wsl_distro_mismatch' }) + }) +}) + +describe('preparationPathKey', () => { + it('normalizes a posix path without folding case', () => { + expect(preparationPathKey('/workspace/./repo/')).toBe('/workspace/repo/') + expect(preparationPathKey('/Workspace/Repo')).toBe('/Workspace/Repo') + }) + + it('folds case for Windows drive and UNC paths, which compare case-insensitively', () => { + expect(preparationPathKey('C:\\Workspace\\Repo')).toBe('c:\\workspace\\repo') + expect(preparationPathKey('\\\\wsl.localhost\\Ubuntu\\home\\jin')).toBe( + '\\\\wsl.localhost\\ubuntu\\home\\jin' + ) + }) +}) diff --git a/src/main/worktree-create-preparation-claim.ts b/src/main/worktree-create-preparation-claim.ts new file mode 100644 index 00000000000..e329282982d --- /dev/null +++ b/src/main/worktree-create-preparation-claim.ts @@ -0,0 +1,130 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { worktreeBaseRefFamily } from '../shared/worktree/base-ref' +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' + +/** The subset of miss reasons this selection can produce; the rest are decided by the caller + * (sparse/existing-branch skips) or by the finalize step. */ +export type PreparationSelectionMissReason = Extract< + PreparedCheckoutMissReason, + | 'none_armed' + | 'repo_mismatch' + | 'base_mismatch' + | 'workspace_root_mismatch' + | 'wsl_distro_mismatch' +> + +export type PreparationCandidate = { + repoPathKey: string + workspaceRootKey: string + wslDistro: string + /** The base exactly as the prefetch handler armed it. */ + baseBranch: string + /** That base after `resolveWorktreeAddBaseRef`, so `main` and `refs/heads/main` compare equal. */ + canonicalBase: string + createdAt: number +} + +export type PreparationRequest = { + repoPathKey: string + workspaceRootKey: string + wslDistro: string + baseBranch: string + /** `null` until the caller has paid the ref probe. A raw-base match resolves without it, so the + * common hit spawns no git at all. */ + canonicalBase: string | null +} + +/** Case-folded on Windows, so the arming and claiming sides key on the same path. */ +export function preparationPathKey(path: string): string { + if (isWindowsAbsolutePathLike(path)) { + return win32.normalize(path).toLowerCase() + } + return posix.normalize(path) +} + +/** Keyed on the canonical base so the prefetch and the create agree when they spell the same ref + * differently; a genuinely different ref still gets its own entry. */ +export function preparationEntryKey( + repoPathKey: string, + workspaceRootKey: string, + canonicalBase: string, + wslDistro: string +): string { + return `${repoPathKey}\0${workspaceRootKey}\0${canonicalBase}\0${wslDistro}` +} + +export type PreparationSelection = + /** `canonicalBase` is echoed back so the caller can re-arm on the create's own base without + * paying the ref probe a second time. */ + | { kind: 'exact'; candidate: T; canonicalBase: string } + | { kind: 'retarget'; candidate: T; canonicalBase: string } + /** Something is armed for this repo but not under this raw base; only a resolved canonical base + * can decide between a hit and a miss. */ + | { kind: 'needs-canonical-base' } + | { kind: 'miss'; reason: PreparationSelectionMissReason } + +/** + * Picks the armed preparation a create may claim. + * + * The two sides of the pool disagree in practice — the prefetch arms `origin/main` while the + * create resolves `main`, or vice versa — and an exact-string key turns every such disagreement + * into a silent cold create. Canonicalizing catches the spelling differences; the base-family + * retarget catches the local-vs-remote-tracking ones, where finalize's existing drift reset lands + * the checkout on the requested commit for far less than a cold add plus a full materialize. + * + * The bound matters: refs outside the same branch family are rejected, because a retarget across + * unrelated history degenerates into a full checkout and wins nothing. + * + * Synchronous on purpose: the caller claims the returned entry in the same run, so two concurrent + * creates cannot both walk away with the same prepared checkout. + */ +export function selectPreparationForCreate( + candidates: readonly T[], + request: PreparationRequest +): PreparationSelection { + if (candidates.length === 0) { + return { kind: 'miss', reason: 'none_armed' } + } + const sameRepo = candidates.filter((candidate) => candidate.repoPathKey === request.repoPathKey) + if (sameRepo.length === 0) { + // Separate from `none_armed`: this is what a size-cap eviction looks like from the create side. + return { kind: 'miss', reason: 'repo_mismatch' } + } + // Distro before root: the distro decides which filesystem the root is even on. + const sameHost = sameRepo.filter((candidate) => candidate.wslDistro === request.wslDistro) + if (sameHost.length === 0) { + return { kind: 'miss', reason: 'wsl_distro_mismatch' } + } + const sameRoot = sameHost.filter( + (candidate) => candidate.workspaceRootKey === request.workspaceRootKey + ) + if (sameRoot.length === 0) { + return { kind: 'miss', reason: 'workspace_root_mismatch' } + } + + const { canonicalBase } = request + if (canonicalBase === null) { + const rawMatch = sameRoot.find((candidate) => candidate.baseBranch === request.baseBranch) + // Same spelling, so the armed entry already holds this request's canonical form. + return rawMatch + ? { kind: 'exact', candidate: rawMatch, canonicalBase: rawMatch.canonicalBase } + : { kind: 'needs-canonical-base' } + } + + const canonicalMatch = sameRoot.find((candidate) => candidate.canonicalBase === canonicalBase) + if (canonicalMatch) { + return { kind: 'exact', candidate: canonicalMatch, canonicalBase } + } + + const family = worktreeBaseRefFamily(canonicalBase) + if (family) { + const retarget = sameRoot + .filter((candidate) => worktreeBaseRefFamily(candidate.canonicalBase) === family) + .sort((left, right) => right.createdAt - left.createdAt)[0] + if (retarget) { + return { kind: 'retarget', candidate: retarget, canonicalBase } + } + } + return { kind: 'miss', reason: 'base_mismatch' } +} diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts new file mode 100644 index 00000000000..26ee1c41aad --- /dev/null +++ b/src/main/worktree-create-preparation-pool.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { + WORKTREE_CREATE_PREPARATION_DIRECTORY, + createWorktreePreparationLockReason +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' +import { toHostFilesystemPath } from './host-tree-removal' +import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' +import { + cleanupStalePreparations, + hasPendingStalePreparationCleanup, + resetStalePreparationCleanupForTests +} from './worktree-create-preparation-stale-cleanup' +import { + discardPreparationWithRetry, + resetPendingPreparationDiscardsForTests, + trackPreparationDiscard +} from './worktree-preparation-discard-retry' + +export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 +export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 + +export type PreparationEntry = { + key: string + repoPath: string + repoPathKey: string + workspaceRoot: string + workspaceRootKey: string + wslDistro: string + baseBranch: string + canonicalBase: string + preparedPath: string + options: AddWorktreeOptions + createdAt: number + ready: Promise + expiration: NodeJS.Timeout +} + +export type StartPreparationArgs = { + repoPath: string + workspaceRoot: string + baseBranch: string + canonicalBase: string + options: AddWorktreeOptions +} + +const preparations = new Map() + +/** One repo on one Git host: the scope a stranded discard is retried under. */ +function preparationHostKey(repoPathKey: string, wslDistro: string): string { + return `${repoPathKey}\0${wslDistro}` +} + +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingPreparations(): boolean { + return preparations.size > 0 || hasPendingStalePreparationCleanup() +} + +function pathOps(path: string): Pick { + return isWindowsAbsolutePathLike(path) ? win32 : posix +} + +async function discardEntry(entry: PreparationEntry): Promise { + // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the + // registration for the same reason the discard here can. Enrol either way. + await entry.ready.catch(() => {}) + await discardPreparationWithRetry({ + hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), + repoPath: entry.repoPath, + preparedPath: entry.preparedPath, + options: entry.options + }) +} + +function discardEntryInBackground(entry: PreparationEntry): void { + // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. + trackPreparationDiscard(discardEntry(entry)) +} + +function expireEntry(entry: PreparationEntry): void { + if (preparations.get(entry.key) !== entry) { + return + } + preparations.delete(entry.key) + discardEntryInBackground(entry) +} + +/** + * Frees a slot for an incoming preparation, preferring one the same workspace already owns. + * + * The cap is a disk bound — a prepared checkout is a full tree, ~200 MB of tracked content in the + * repo this was measured against — so it stays small. But flipping through the composer's base + * picker arms several preparations for one repo, and a plain oldest-first eviction let that churn + * throw away another project's warm checkout, which is a structural miss for anyone working across + * several repos. Evict the incoming workspace's own oldest entry first; only reach across + * workspaces when this one holds none. + */ +function enforcePreparationLimit( + repoPathKey: string, + workspaceRootKey: string, + wslDistro: string +): void { + while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { + const byAge = [...preparations.values()].sort((left, right) => left.createdAt - right.createdAt) + const victim = + byAge.find( + (entry) => + entry.repoPathKey === repoPathKey && + entry.workspaceRootKey === workspaceRootKey && + entry.wslDistro === wslDistro + ) ?? byAge[0] + if (!victim) { + return + } + preparations.delete(victim.key) + clearTimeout(victim.expiration) + discardEntryInBackground(victim) + } +} + +export function listPreparations(): PreparationEntry[] { + return [...preparations.values()] +} + +export function findPreparation( + repoPathKey: string, + workspaceRootKey: string, + canonicalBase: string, + wslDistro: string +): PreparationEntry | undefined { + return preparations.get( + preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro) + ) +} + +/** Removes an entry from the pool so no other create can claim it. Callers must run this in the + * same synchronous turn as the selection that produced `entry`. */ +export function takePreparation(entry: PreparationEntry): void { + preparations.delete(entry.key) + clearTimeout(entry.expiration) +} + +export function startPreparation({ + repoPath, + workspaceRoot, + baseBranch, + canonicalBase, + options +}: StartPreparationArgs): Promise { + const repoPathKey = preparationPathKey(repoPath) + const workspaceRootKey = preparationPathKey(workspaceRoot) + const wslDistro = options.wslDistro ?? '' + const key = preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro) + enforcePreparationLimit(repoPathKey, workspaceRootKey, wslDistro) + const preparationId = `${process.pid}-${randomUUID()}` + const lockReason = createWorktreePreparationLockReason(preparationId) + const preparationRoot = pathOps(workspaceRoot).join( + workspaceRoot, + WORKTREE_CREATE_PREPARATION_DIRECTORY + ) + const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId) + const entry = {} as PreparationEntry + const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) + expiration.unref() + Object.assign(entry, { + key, + repoPath, + repoPathKey, + workspaceRoot, + workspaceRootKey, + wslDistro, + baseBranch, + canonicalBase, + preparedPath, + options, + createdAt: Date.now(), + expiration, + ready: (async () => { + 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) + })() + } satisfies PreparationEntry) + preparations.set(key, entry) + void entry.ready.catch(() => { + if (preparations.get(key) === entry) { + preparations.delete(key) + clearTimeout(entry.expiration) + } + }) + return entry.ready +} + +export async function _resetPreparationPoolForTests(): Promise { + const entries = [...preparations.values()] + preparations.clear() + resetStalePreparationCleanupForTests() + await Promise.all( + entries.map(async (entry) => { + clearTimeout(entry.expiration) + await discardEntry(entry) + }) + ) + await resetPendingPreparationDiscardsForTests() +} diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts new file mode 100644 index 00000000000..fd02b7cc0d1 --- /dev/null +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -0,0 +1,84 @@ +import { + isWorktreeCreatePreparation, + parseWorktreePreparationOwnerPid, + parseWorktreePreparationPathOwnerPid +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { listWorktreeGraph } from './git/worktree' +import { discardPreparedWorktree, unlockPreparedWorktree } from './git/worktree-create-preparation' +import { retryPendingPreparationDiscards } from './worktree-preparation-discard-retry' + +const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 + +const staleCleanupInFlight = new Map>() + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst + * of arming calls shares one worktree listing. */ +export async function cleanupStalePreparations( + cleanupKey: string, + repoPath: string, + options: AddWorktreeOptions +): Promise { + const existing = staleCleanupInFlight.get(cleanupKey) + if (existing) { + await existing.catch(() => {}) + return + } + const cleanup = (async () => { + // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus + // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. + void retryPendingPreparationDiscards(cleanupKey) + const worktrees = await listWorktreeGraph(repoPath, { + ...options, + includeCreatePreparations: true + }) + const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) + let nextIndex = 0 + async function discardNextStalePreparation(): Promise { + while (nextIndex < staleWorktrees.length) { + const worktree = staleWorktrees[nextIndex] + nextIndex += 1 + const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) + const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) + if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + continue + } + // Preserve a branch-attached final path after a crash; only detached or + // still-hidden preparations are safe to discard automatically. + if (worktree.branch && pathOwnerPid === null) { + await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } else if (pathOwnerPid === lockOwnerPid) { + await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } + } + } + const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) + })() + staleCleanupInFlight.set(cleanupKey, cleanup) + try { + await cleanup.catch(() => {}) + } finally { + if (staleCleanupInFlight.get(cleanupKey) === cleanup) { + staleCleanupInFlight.delete(cleanupKey) + } + } +} + +/** True while a crash-recovery scan is running, which means a create is in flight or imminent. */ +export function hasPendingStalePreparationCleanup(): boolean { + return staleCleanupInFlight.size > 0 +} + +export function resetStalePreparationCleanupForTests(): void { + staleCleanupInFlight.clear() +} diff --git a/src/main/worktree-create-preparation-wsl-root.test.ts b/src/main/worktree-create-preparation-wsl-root.test.ts index f0c8e664298..2c752b74a80 100644 --- a/src/main/worktree-create-preparation-wsl-root.test.ts +++ b/src/main/worktree-create-preparation-wsl-root.test.ts @@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({ getWorktreeOptions: vi.fn(), getMirrorDistro: vi.fn(), getWslHome: vi.fn(), - getWslHomeAsync: vi.fn() + getWslHomeAsync: vi.fn(), + resolveBaseRef: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -27,6 +28,9 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: mocks.getMirrorDistro @@ -86,6 +90,9 @@ beforeEach(() => { throw new Error('the blocking wsl.exe home probe must not run while preparing') }) mocks.getWslHomeAsync.mockReset().mockResolvedValue(WSL_HOME) + mocks.resolveBaseRef + .mockReset() + .mockImplementation(async (_repoPath: string, baseRef: string) => `refs/remotes/${baseRef}`) }) afterEach(async () => { diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 3e03643d6a8..06818fec422 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' +import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), @@ -12,7 +13,9 @@ const mocks = vi.hoisted(() => ({ unlock: vi.fn(), getWorktreeOptions: vi.fn(), computeWorkspaceRoot: vi.fn(), - computeWorkspaceRootAsync: vi.fn() + computeWorkspaceRootAsync: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -23,6 +26,12 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('./git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: () => undefined @@ -39,6 +48,7 @@ vi.mock('./ipc/worktree-logic', () => ({ import { _resetWorktreeCreatePreparationsForTests, consumePreparedWorktreeCreate, + hasPendingWorktreeCreatePreparations, prepareWorktreeCreateForRepo } from './worktree-create-preparation' @@ -47,6 +57,11 @@ function flushBackgroundWork(ms = 0): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } +const EXISTING_REFS = new Set([ + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/origin/release' +]) const repo = { id: 'repo-1', path: '/repo' } as Repo const store = { getSettings: () => ({}) } as unknown as Store @@ -58,6 +73,12 @@ beforeEach(() => { mocks.discard.mockReset().mockResolvedValue(undefined) mocks.unlock.mockReset().mockResolvedValue(undefined) mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.measureDivergence.mockReset().mockResolvedValue('within') + mocks.resolveBaseRef + .mockReset() + .mockImplementation((_repoPath: string, baseRef: string) => + resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) + ) mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { throw new Error('synchronous workspace-root lookup must not run on the main thread') }) @@ -134,7 +155,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) - it('does not claim a preparation after the selected base changes', async () => { + it('does not claim a preparation after the selected base changes to another branch', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await expect( @@ -145,10 +166,185 @@ describe('worktree create preparation registry', () => { branch: 'feature/test', baseBranch: 'origin/release' }) - ).resolves.toBeNull() + ).resolves.toEqual({ status: 'miss', reason: 'base_mismatch' }) expect(mocks.finalize).not.toHaveBeenCalled() }) + it('claims across the local/remote spelling of the same base and reports the retarget', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + // `main` has no local ref here, so the canonical forms differ and only the base family matches. + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: true, result: {} }) + // Finalize still receives the requested base, so it resets onto the requested commit. + expect(mocks.finalize).toHaveBeenCalledWith( + repo.path, + expect.any(String), + '/workspace/final', + 'feature/test', + 'main', + undefined, + {} + ) + }) + + it('refuses a same-family retarget whose bases have drifted too far apart', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // An abandoned fork's `main` is the same base family but a whole-tree checkout away. + mocks.measureDivergence.mockResolvedValue('exceeded') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'retarget_too_divergent' }) + expect(mocks.finalize).not.toHaveBeenCalled() + // The preparation is left armed for the base it actually holds. + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('separates a drift check that said no from one that could not answer', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // A timed-out or aborted walk skipped a retarget that may well have been cheap; that is a + // tuning signal, not the bound working as intended, so it must not report as excess drift. + mocks.measureDivergence.mockResolvedValue('unknown') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'retarget_unverifiable' }) + expect(mocks.finalize).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('does not spend a divergence walk when the base matches exactly', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + + expect(mocks.measureDivergence).not.toHaveBeenCalled() + }) + + it('claims when the two sides spell the same ref differently', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: false, result: {} }) + }) + + it('never hands the same prepared checkout to two concurrent creates', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + // `main` needs the ref probe, so the claim has to await mid-flight — the window where a + // second create could otherwise walk away with the same preparation. + const [first, second] = await Promise.all([ + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/first', + branch: 'feature/first', + baseBranch: 'main' + }), + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/second', + branch: 'feature/second', + baseBranch: 'main' + }) + ]) + + expect([first.status, second.status]).toContain('hit') + const preparedPaths = mocks.finalize.mock.calls.map((call) => call[1]) + expect(new Set(preparedPaths).size).toBe(preparedPaths.length) + }) + + it('reports which part of the claim key disagreed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/other-workspace', + worktreePath: '/other-workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'workspace_root_mismatch' }) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main', + options: { wslDistro: 'Ubuntu' } + }) + ).resolves.toEqual({ status: 'miss', reason: 'wsl_distro_mismatch' }) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: '/other-repo', + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'repo_mismatch' }) + expect(mocks.finalize).not.toHaveBeenCalled() + }) + + it("evicts a repo's own stale preparation before another repo's", async () => { + const otherRepo = { id: 'repo-2', path: '/other-repo' } as Repo + await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') + // Fill the pool from one repo, as flipping the composer's base picker does, until the next + // arm has to evict something. + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'origin/release') + await prepareWorktreeCreateForRepo(store, repo, 'main') + + // The eviction must cost `repo` a slot, not `otherRepo` its warm checkout. + await expect( + consumePreparedWorktreeCreate({ + repoPath: otherRepo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/other', + branch: 'feature/other', + baseBranch: 'origin/main' + }) + ).resolves.toMatchObject({ status: 'hit' }) + }) + it('routes preparation and finalization through the selected WSL runtime', async () => { const options = { wslDistro: 'Ubuntu' } mocks.getWorktreeOptions.mockReturnValue(options) @@ -166,7 +362,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledWith( repo.path, expect.any(String), - 'origin/main', + 'refs/remotes/origin/main', expect.any(String), options ) @@ -246,6 +442,76 @@ describe('worktree create preparation registry', () => { ) }) + it('cleans up and reports a finalize miss so normal add can run', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'finalize_failed' }) + expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) + expect(mocks.discard).toHaveBeenCalledTimes(1) + }) + + async function consumeOnce(name: string): Promise { + await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: `/workspace/${name}`, + branch: `feature/${name}`, + baseBranch: 'origin/main' + }) + } + + it('does not re-arm after an isolated create', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('only') + + // Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL. + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('re-arms a preparation once creates arrive in a burst', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('first') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('second') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + // The replacement is claimable, so a third create still skips the cold add. + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/third', + branch: 'feature/third', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: false, result: {} }) + expect(mocks.finalize).toHaveBeenCalledTimes(3) + }) + + it('does not re-arm when finalization failed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('first') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.prepareCheckout.mockClear() + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await consumeOnce('second') + + expect(mocks.prepareCheckout).not.toHaveBeenCalled() + }) + it('retries a discard that failed while this process is still alive', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string @@ -285,10 +551,14 @@ describe('worktree create preparation registry', () => { unremovable.add(leakedHere) unremovable.add(leakedElsewhere) - // Evict both, oldest first, so each host has one recorded discard failure. - for (const base of ['origin/one', 'origin/two', 'origin/three']) { + // Evict through each host's own arming: eviction prefers the incoming workspace's oldest + // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. + for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, repo, base) } + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, otherRepo, base) + } await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {}) @@ -378,11 +648,15 @@ describe('worktree create preparation registry', () => { unremovable.add(leakedOnUbuntu) unremovable.add(leakedOnDebian) - // Evict both, oldest first, so each distro has one recorded discard failure. + // Evict through each distro's own arming: the eviction scope includes the distro, so arming + // under Ubuntu no longer reaches across and takes the Debian entry. mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - for (const base of ['origin/one', 'origin/two', 'origin/three']) { + for (const base of ['origin/one', 'origin/two']) { await prepareWorktreeCreateForRepo(store, repo, base) } + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/one') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) await flushBackgroundWork() expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' }) @@ -450,20 +724,24 @@ describe('worktree create preparation registry', () => { expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) }) - it('cleans up and returns null so normal add can run when finalization fails', async () => { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) - - await expect( - consumePreparedWorktreeCreate({ - repoPath: repo.path, - workspaceRoot: '/workspace', - worktreePath: '/workspace/final', - branch: 'feature/test', - baseBranch: 'origin/main' + it('reports a pending create while a stale-cleanup scan is running', async () => { + let releaseListing!: () => void + mocks.listWorktreeGraph.mockReturnValueOnce( + new Promise((resolve) => { + releaseListing = () => resolve([]) }) - ).resolves.toBeNull() - expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) - expect(mocks.discard).toHaveBeenCalledTimes(1) + ) + const arming = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // Anchor on the scan actually starting, not on a fixed number of microtasks: an await added + // ahead of it would otherwise make this pass vacuously rather than fail. + while (mocks.listWorktreeGraph.mock.calls.length === 0) { + await Promise.resolve() + } + + // Why: the idle gate must not start repo maintenance while crash recovery is mid-scan. + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + + releaseListing() + await arming }) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index ff7478cb46e..b13916194ca 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -1,53 +1,52 @@ -import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import { posix, win32 } from 'node:path' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import { - WORKTREE_CREATE_PREPARATION_DIRECTORY, - createWorktreePreparationLockReason, - isWorktreeCreatePreparation, - parseWorktreePreparationOwnerPid, - parseWorktreePreparationPathOwnerPid -} from '../shared/worktree/create-preparation' +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { listWorktreeGraph } from './git/worktree' +import { measureRetargetDivergence } from './git/worktree-base-divergence' +import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' +import { + _resetPreparationPoolForTests, + findPreparation, + hasPendingPreparations, + listPreparations, + startPreparation, + takePreparation, + type PreparationEntry +} from './worktree-create-preparation-pool' import { discardPreparedWorktree, - finalizePreparedWorktree, - unlockPreparedWorktree, - prepareWorktreeCreateCheckout + finalizePreparedWorktree } from './git/worktree-create-preparation' import { getLocalProjectWorktreeGitOptions, getWorktreeMirrorDistro } from './project-runtime-git-options' import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic' -import { toHostFilesystemPath } from './host-tree-removal' import { - discardPreparationWithRetry, - resetPendingPreparationDiscardsForTests, - retryPendingPreparationDiscards, - trackPreparationDiscard -} from './worktree-preparation-discard-retry' + recordPreparationConsume, + resetPreparationConsumeHistoryForTests +} from './worktree-create-preparation-burst' +import { toHostFilesystemPath } from './host-tree-removal' -export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 -export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 -const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 +export { + WORKTREE_CREATE_PREPARATION_LIMIT, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' -type PreparationEntry = { - key: string - repoPath: string - workspaceRoot: string - preparedPath: string - options: AddWorktreeOptions - createdAt: number - ready: Promise - expiration: NodeJS.Timeout +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingWorktreeCreatePreparations(): boolean { + return hasPendingPreparations() } +export type PreparedWorktreeCreateAttempt = + | { status: 'hit'; retargeted: boolean; result: AddWorktreeResult } + | { status: 'miss'; reason: PreparedCheckoutMissReason } + type ConsumePreparedWorktreeArgs = { repoPath: string workspaceRoot: string @@ -58,128 +57,16 @@ type ConsumePreparedWorktreeArgs = { options?: AddWorktreeOptions } -const preparations = new Map() -const staleCleanupInFlight = new Map>() - -function pathOps(path: string): Pick { - return isWindowsAbsolutePathLike(path) ? win32 : posix -} - -function pathKey(path: string): string { - const normalized = pathOps(path).normalize(path) - return isWindowsAbsolutePathLike(path) ? normalized.toLowerCase() : normalized -} - -function preparationKey( +function canonicalBaseRef( repoPath: string, - workspaceRoot: string, baseBranch: string, options: AddWorktreeOptions -): string { - return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}` -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -function preparationHostKey(repoPath: string, options: AddWorktreeOptions): string { - return `${pathKey(repoPath)}\0${options.wslDistro ?? ''}` -} - -async function discardEntry(entry: PreparationEntry): Promise { - // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the - // registration for the same reason the discard here can. Enrol either way. - await entry.ready.catch(() => {}) - await discardPreparationWithRetry({ - hostKey: preparationHostKey(entry.repoPath, entry.options), - repoPath: entry.repoPath, - preparedPath: entry.preparedPath, - options: entry.options - }) -} - -function discardEntryInBackground(entry: PreparationEntry): void { - // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. - trackPreparationDiscard(discardEntry(entry)) -} - -function expireEntry(entry: PreparationEntry): void { - if (preparations.get(entry.key) !== entry) { - return - } - preparations.delete(entry.key) - discardEntryInBackground(entry) -} - -function enforcePreparationLimit(): void { - while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { - const oldest = [...preparations.values()].sort( - (left, right) => left.createdAt - right.createdAt - )[0] - if (!oldest) { - return - } - preparations.delete(oldest.key) - clearTimeout(oldest.expiration) - discardEntryInBackground(oldest) - } -} - -async function cleanupStalePreparations( - repoPath: string, - options: AddWorktreeOptions -): Promise { - const cleanupKey = preparationHostKey(repoPath, options) - const existing = staleCleanupInFlight.get(cleanupKey) - if (existing) { - await existing.catch(() => {}) - return - } - const cleanup = (async () => { - // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus - // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. - void retryPendingPreparationDiscards(cleanupKey) - const worktrees = await listWorktreeGraph(repoPath, { - ...options, - includeCreatePreparations: true - }) - const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) - let nextIndex = 0 - async function discardNextStalePreparation(): Promise { - while (nextIndex < staleWorktrees.length) { - const worktree = staleWorktrees[nextIndex] - nextIndex += 1 - const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) - const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) - if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { - continue - } - // Preserve a branch-attached final path after a crash; only detached or - // still-hidden preparations are safe to discard automatically. - if (worktree.branch && pathOwnerPid === null) { - await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } else if (pathOwnerPid === lockOwnerPid) { - await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } - } - } - const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) - await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) - })() - staleCleanupInFlight.set(cleanupKey, cleanup) - try { - await cleanup.catch(() => {}) - } finally { - if (staleCleanupInFlight.get(cleanupKey) === cleanup) { - staleCleanupInFlight.delete(cleanupKey) - } - } +): Promise { + return resolveLocalWorktreeBaseRef( + repoPath, + baseBranch, + options.wslDistro ? { wslDistro: options.wslDistro } : {} + ) } export async function prepareWorktreeCreateForRepo( @@ -199,91 +86,147 @@ export async function prepareWorktreeCreateForRepo( repo.path, getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo)) ) - const key = preparationKey(repo.path, workspaceRoot, baseBranch, options) - const existing = preparations.get(key) + const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options) + const existing = findPreparation( + preparationPathKey(repo.path), + preparationPathKey(workspaceRoot), + canonicalBase, + options.wslDistro ?? '' + ) if (existing) { return existing.ready } - enforcePreparationLimit() - const preparationId = `${process.pid}-${randomUUID()}` - const lockReason = createWorktreePreparationLockReason(preparationId) - const preparedPath = pathOps(workspaceRoot).join( - workspaceRoot, - WORKTREE_CREATE_PREPARATION_DIRECTORY, - preparationId - ) - const entry = {} as PreparationEntry - const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) - expiration.unref() - Object.assign(entry, { - key, + return startPreparation({ repoPath: repo.path, workspaceRoot, - preparedPath, - options, - createdAt: Date.now(), - expiration, - ready: (async () => { - await cleanupStalePreparations(repo.path, options) - await mkdir( - toHostFilesystemPath( - pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) - ), - { recursive: true } - ) - await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options) - })() - } satisfies PreparationEntry) - preparations.set(key, entry) - void entry.ready.catch(() => { - if (preparations.get(key) === entry) { - preparations.delete(key) - clearTimeout(entry.expiration) - } + baseBranch, + canonicalBase, + options }) - return entry.ready } +type ClaimedPreparation = + | { status: 'claimed'; entry: PreparationEntry; retargeted: boolean; canonicalBase: string } + | { status: 'miss'; reason: PreparedCheckoutMissReason } + async function claimPreparedWorktree( - repoPath: string, - workspaceRoot: string, - baseBranch: string, + args: ConsumePreparedWorktreeArgs, options: AddWorktreeOptions -): Promise { - const key = preparationKey(repoPath, workspaceRoot, baseBranch, options) - const entry = preparations.get(key) - if (!entry) { - return null +): Promise { + const request = { + repoPathKey: preparationPathKey(args.repoPath), + workspaceRootKey: preparationPathKey(args.workspaceRoot), + wslDistro: options.wslDistro ?? '', + baseBranch: args.baseBranch } - preparations.delete(key) - clearTimeout(entry.expiration) + let selection = selectPreparationForCreate(listPreparations(), { + ...request, + canonicalBase: null + }) + if (selection.kind === 'needs-canonical-base') { + // The probe is the only await here, and the pool is re-read after it, so the select-and-take + // below stays one synchronous run and no other create can hold the same entry. + const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options) + selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) + } + if (selection.kind !== 'exact' && selection.kind !== 'retarget') { + return { + status: 'miss', + reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' + } + } + if (selection.kind === 'retarget') { + const candidate = selection.candidate + const { canonicalBase } = selection + const divergence = await measureRetargetDivergence( + args.repoPath, + candidate.canonicalBase, + canonicalBase, + { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + // Why forward it: a cancelled create must stop these probes now, not at the deadline. + ...(options.signal ? { signal: options.signal } : {}) + } + ) + if (divergence !== 'within') { + return { + status: 'miss', + reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' + } + } + // Re-select after the walk: the pool may have gained an exact match or lost this entry. A + // different retarget candidate is left for the next create rather than claimed unverified. + selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) + if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { + return { status: 'miss', reason: 'base_mismatch' } + } + if (selection.kind === 'retarget' && selection.candidate !== candidate) { + return { status: 'miss', reason: 'base_mismatch' } + } + } + const entry = selection.candidate + takePreparation(entry) try { await entry.ready - return entry + return { + status: 'claimed', + entry, + retargeted: selection.kind === 'retarget', + canonicalBase: selection.canonicalBase + } } catch { - return null + return { status: 'miss', reason: 'prepare_failed' } } } +/** Replaces a just-consumed preparation, re-armed on the base the create actually used so the + * next one hits exactly — but only once the user has shown they are creating in a burst. A + * replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one after an + * isolated create spends that on nobody. Never awaited: create has already returned by the time + * the replacement checkout finishes. */ +function rearmPreparation( + entry: PreparationEntry, + baseBranch: string, + canonicalBase: string +): void { + // Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the + // consume, and the next create would look isolated when it is really the middle of a burst. + const continuesBurst = recordPreparationConsume(entry.key) + if ( + !continuesBurst || + findPreparation(entry.repoPathKey, entry.workspaceRootKey, canonicalBase, entry.wslDistro) + ) { + return + } + void startPreparation({ + repoPath: entry.repoPath, + workspaceRoot: entry.workspaceRoot, + baseBranch, + canonicalBase, + options: entry.options + }).catch(() => { + // Why: a warm-up failure is recovered by the normal add on the next create. + }) +} + export async function consumePreparedWorktreeCreate( args: ConsumePreparedWorktreeArgs -): Promise { +): Promise { const options = args.options ?? {} - const entry = await claimPreparedWorktree( - args.repoPath, - args.workspaceRoot, - args.baseBranch, - options - ) - if (!entry) { - return null + const claim = await claimPreparedWorktree(args, options) + if (claim.status === 'miss') { + return { status: 'miss', reason: claim.reason } } + const { entry } = claim try { - await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), { - recursive: true - }) - return await finalizePreparedWorktree( + const parentDir = isWindowsAbsolutePathLike(args.worktreePath) + ? win32.dirname(args.worktreePath) + : posix.dirname(args.worktreePath) + await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) + // Finalize resolves the requested base itself and resets the prepared checkout onto that + // commit, so a retargeted claim is handed over at the requested commit or not at all. + const result = await finalizePreparedWorktree( args.repoPath, entry.preparedPath, args.worktreePath, @@ -292,25 +235,21 @@ export async function consumePreparedWorktreeCreate( args.refreshLocalBaseRef, options ) + // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is + // creating in a burst; the TTL and the preparation limit still bound an unused replacement. + rearmPreparation(entry, args.baseBranch, claim.canonicalBase) + return { status: 'hit', retargeted: claim.retargeted, result } } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) console.warn( '[worktree-create] prepared checkout could not be finalized; using normal add', error ) - return null + return { status: 'miss', reason: 'finalize_failed' } } } export async function _resetWorktreeCreatePreparationsForTests(): Promise { - const entries = [...preparations.values()] - preparations.clear() - staleCleanupInFlight.clear() - await Promise.all( - entries.map(async (entry) => { - clearTimeout(entry.expiration) - await discardEntry(entry) - }) - ) - await resetPendingPreparationDiscardsForTests() + resetPreparationConsumeHistoryForTests() + await _resetPreparationPoolForTests() } diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts index 433a9ac0098..bc1e49f4842 100644 --- a/src/main/worktree-create-timing.ts +++ b/src/main/worktree-create-timing.ts @@ -1,4 +1,5 @@ import type { + PreparedCheckoutOutcome, WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/worktree/create-types' @@ -8,6 +9,7 @@ type TimingClock = () => number export type WorktreeCreateTimingRecorder = { time(phase: string, operation: () => Promise): Promise timeSync(phase: string, operation: () => T): T + recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void finish(): WorktreeCreateTiming } @@ -37,6 +39,7 @@ export function createWorktreeCreateTimingRecorder( ): WorktreeCreateTimingRecorder { const startedAt = clock() const phases: WorktreeCreateTimingPhase[] = [] + let preparedCheckout: PreparedCheckoutOutcome | undefined const recordPhase = (phase: string, operationStartedAt: number): void => { phases.push(createPhase(phase, operationStartedAt, clock(), startedAt)) @@ -59,10 +62,14 @@ export function createWorktreeCreateTimingRecorder( recordPhase(phase, operationStartedAt) } }, + recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void { + preparedCheckout = outcome + }, finish() { return { totalDurationMs: clampDuration(clock() - startedAt), - phases: [...phases] + phases: [...phases], + ...(preparedCheckout ? { preparedCheckout } : {}) } } } diff --git a/src/main/worktree-identity-persistence.test.ts b/src/main/worktree-identity-persistence.test.ts index c66a2d72ce8..0b6dd178e84 100644 --- a/src/main/worktree-identity-persistence.test.ts +++ b/src/main/worktree-identity-persistence.test.ts @@ -5,12 +5,26 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { canonicalWorktreeIdentity } from '../shared/worktree/identity' import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' -import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' +import type { Store } from './persistence/loading-store/store' +import { + createStore, + makeRepo, + readDataFile, + testState, + writeDataFile +} from './persistence-test-harness' describe('host-qualified worktree metadata', () => { const worktreeId = 'repo-1::/workspace/feature' const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444' + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const createStoreWithRepo = (): Store => { + const store = createStore() + store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' })) + return store + } + beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-')) }) @@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => { }) }) it('reloads host-specific metadata without collapsing it to the legacy locator', () => { - const store = createStore() + const store = createStoreWithRepo() store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) store.flush() @@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => { expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after') }) it('backfills one stable instance for legacy metadata that omitted it', () => { - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' }) seed.flush() const legacy = readDataFile() as PersistedState @@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => { // Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree // listing loop, taking every workspace in the repo down with it and never self-healing. it('collapses an ambiguous locator onto its most recently active instance', () => { - const seed = createStore() + const seed = createStoreWithRepo() const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => { it('repairs a missing canonical instance id while re-adopting an SSH target', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => { it('deduplicates an equivalent destination during SSH target re-adoption', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState 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/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts index e5e45d2c22a..cbb7d2da7c5 100644 --- a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -16,7 +16,7 @@ */ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -382,9 +382,12 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui // value this wrapper cannot use degrades to $HOME, where zsh itself looks. const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) try { + // Unique per run: a fixed name here shares one path with every other run in + // the system temp dir, so a killed run leaves a stale directory behind and + // every later rename onto it fails with ENOTEMPTY. const { values } = await runFromRelocatedRoot( home, - join(dirname(userDataPath), '홍길동-wsl-view') + join(dirname(userDataPath), `홍길동-${basename(userDataPath)}`) ) expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5bc0757c5ca..b5ac5c8b5b2 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' +import type { PreloadApi } from '../api-types' export const agentStatusApi = { /** Listen for agent status updates forwarded from native hook receivers. */ @@ -85,4 +86,4 @@ export const agentStatusApi = { }): void => { ipcRenderer.send('agentStatus:transferPaneAuthority', args) } -} +} satisfies PreloadApi['agentStatus'] diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts index f27146d9cd3..5aca3fd805c 100644 --- a/src/preload/api/agent-trust-bridge.ts +++ b/src/preload/api/agent-trust-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const agentTrustApi = { markTrusted: (args: { @@ -6,4 +7,4 @@ export const agentTrustApi = { workspacePath: string connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) -} +} satisfies PreloadApi['agentTrust'] diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index ea9b2e1be14..917c9f02b63 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -10,19 +10,19 @@ import type { } from '../../shared/ai-vault-types' import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import type { PreloadApi } from '../api-types' export const aiVaultApi = { - listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), - resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => + listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => ipcRenderer.invoke('aiVault:resolveSessionTitles', args), cancelListSessions: (args: { requestToken: string }): Promise => ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => ipcRenderer.invoke('aiVault:prepareSessionResume', args), - listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => + listSubagentSessions: (args: AiVaultSubagentListArgs) => ipcRenderer.invoke('aiVault:listSubagentSessions', args), - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), deleteSession: (args: AiVaultDeleteSessionArgs): Promise => ipcRenderer.invoke('aiVault:deleteSession', args), @@ -31,4 +31,4 @@ export const aiVaultApi = { ipcRenderer.on('aiVault:windowFocused', listener) return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) } -} +} satisfies PreloadApi['aiVault'] diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 705d24e4bda..22d46cc40d2 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -40,6 +40,7 @@ export const appApi = { throw new Error('Failed to stage renderer state before unload.') } }, + awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), prepareTerminalStartupRestoration: (): Promise => @@ -73,4 +74,4 @@ export const appApi = { ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) -} +} satisfies PreloadApi['app'] diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts index 87bec12a8e9..43c3df528d8 100644 --- a/src/preload/api/automations-bridge.ts +++ b/src/preload/api/automations-bridge.ts @@ -1,5 +1,5 @@ import { ipcRenderer } from 'electron' -import type { ExternalAutomationManagerResult } from '../api-types' +import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types' import type { AutomationDispatchRequest, AutomationDispatchResult, @@ -56,4 +56,4 @@ export const automationsApi = { ipcRenderer.on('automations:changed', listener) return () => ipcRenderer.removeListener('automations:changed', listener) } -} +} satisfies PreloadApi['automations'] diff --git a/src/preload/api/bitbucket-bridge.ts b/src/preload/api/bitbucket-bridge.ts index cf51ce453df..dd683b1387b 100644 --- a/src/preload/api/bitbucket-bridge.ts +++ b/src/preload/api/bitbucket-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const bitbucketApi = { connect: (args: { @@ -12,5 +13,5 @@ export const bitbucketApi = { disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), - status: (): Promise => ipcRenderer.invoke('bitbucket:status') -} + status: () => ipcRenderer.invoke('bitbucket:status') +} satisfies PreloadApi['bitbucket'] diff --git a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 3714a3bca7c..9d782971d96 100644 --- a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -6,6 +6,7 @@ import type { } from '../../shared/browser-webauthn-account' import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' import { browserClientPageRendererRequests } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const browserGuestRegistrationAndDownloadsApi = { onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, @@ -194,4 +195,4 @@ export const browserGuestRegistrationAndDownloadsApi = { ipcRenderer.on('browser:download-finished', listener) return () => ipcRenderer.removeListener('browser:download-finished', listener) } -} +} satisfies Partial diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 0e959e0af4f..93d6001e61c 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -81,23 +82,17 @@ export const browserPageInteractionAndSessionsApi = { }, cancelDownload: (args: { downloadId: string }): Promise => ipcRenderer.invoke('browser:cancelDownload', args), - setGrabMode: (args: { - browserPageId: string - enabled: boolean - }): Promise<{ ok: true } | { ok: false; reason: string }> => + setGrabMode: (args: { browserPageId: string; enabled: boolean }) => ipcRenderer.invoke('browser:setGrabMode', args), - awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => + awaitGrabSelection: (args: { browserPageId: string; opId: string }) => ipcRenderer.invoke('browser:awaitGrabSelection', args), cancelGrab: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:cancelGrab', args), captureSelectionScreenshot: (args: { browserPageId: string rect: { x: number; y: number; width: number; height: number } - }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), - extractHoverPayload: (args: { - browserPageId: string - }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => + }) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + extractHoverPayload: (args: { browserPageId: string }) => ipcRenderer.invoke('browser:extractHoverPayload', args), onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => @@ -115,7 +110,7 @@ export const browserPageInteractionAndSessionsApi = { ipcRenderer.on('browser:grabActionShortcut', listener) return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) }, - sessionListProfiles: (): Promise => ipcRenderer.invoke('browser:session:listProfiles'), + sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'), prepareSshWorkspacePartition: (args: { targetId: string browserProfileId?: string @@ -126,40 +121,28 @@ export const browserPageInteractionAndSessionsApi = { scope: 'default' | 'isolated' | 'imported' label: string userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + }) => ipcRenderer.invoke('browser:session:createProfile', args), sessionDeleteProfile: (args: { profileId: string }): Promise => ipcRenderer.invoke('browser:session:deleteProfile', args), - sessionImportCookies: (args: { - profileId: string - }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + sessionImportCookies: (args: { profileId: string }) => ipcRenderer.invoke('browser:session:importCookies', args), sessionResolvePartition: (args: { profileId: string | null }): Promise => ipcRenderer.invoke('browser:session:resolvePartition', args), - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), - sessionDetectBrowsersForClientHost: (args: { - environmentId: string - }): Promise => + sessionDetectBrowsers: () => ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsersForClientHost: (args: { environmentId: string }) => ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + sessionImportFromBrowser: (args: { profileId: string; browserFamily: string }) => ipcRenderer.invoke('browser:session:importFromBrowser', args), sessionImportFromBrowserForClientHost: (args: { environmentId: string profileId: string browserFamily: string browserProfile?: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), - sessionClientRouteImportSources: (args: { - environmentId: string - }): Promise> => + }) => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + sessionClientRouteImportSources: (args: { environmentId: string }) => ipcRenderer.invoke('browser:session:clientRouteImportSources', args), sessionClearDefaultCookies: (): Promise => ipcRenderer.invoke('browser:session:clearDefaultCookies'), notifyActiveTabChanged: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:activeTabChanged', args) -} +} satisfies Partial diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts index dca222c5843..d29b7365dc2 100644 --- a/src/preload/api/browser-bridge.ts +++ b/src/preload/api/browser-bridge.ts @@ -1,7 +1,8 @@ import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' +import type { PreloadApi } from '../api-types' export const browserApi = { ...browserGuestRegistrationAndDownloadsApi, ...browserPageInteractionAndSessionsApi -} +} satisfies PreloadApi['browser'] diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts index 8200c17791d..69586525962 100644 --- a/src/preload/api/claude-accounts-bridge.ts +++ b/src/preload/api/claude-accounts-bridge.ts @@ -1,18 +1,18 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const claudeAccountsApi = { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + list: () => ipcRenderer.invoke('claudeAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => ipcRenderer.invoke('claudeAccounts:add', args), cancelPendingLogin: (): Promise => ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), - reauthenticate: (args: { accountId: string }): Promise => + reauthenticate: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) -} + }) => ipcRenderer.invoke('claudeAccounts:select', args) +} satisfies PreloadApi['claudeAccounts'] diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts index 0c81e35e3e8..1b98202d88c 100644 --- a/src/preload/api/claude-usage-bridge.ts +++ b/src/preload/api/claude-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage') +export const claudeUsageApi = createUsageProviderApi( + ipcRenderer, + 'claudeUsage' +) satisfies PreloadApi['claudeUsage'] diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts index 8f811cbdd40..76b574a2f44 100644 --- a/src/preload/api/cli-bridge.ts +++ b/src/preload/api/cli-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' +import type { PreloadApi } from '../api-types' export const cliApi = { getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), @@ -11,4 +12,4 @@ export const cliApi = { ipcRenderer.invoke('cli:installWsl', args), removeWsl: (args?: { distro?: string | null }): Promise => ipcRenderer.invoke('cli:removeWsl', args) -} +} satisfies PreloadApi['cli'] diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index ecd32e4b923..d085de45855 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -1,20 +1,18 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const codexAccountsApi = { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + list: () => ipcRenderer.invoke('codexAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { - accountId: string - activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), + reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) => + ipcRenderer.invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), + }) => ipcRenderer.invoke('codexAccounts:select', args), listStalePanes: (args: { ptyIds: string[] }): Promise< @@ -29,4 +27,4 @@ export const codexAccountsApi = { ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) -} +} satisfies PreloadApi['codexAccounts'] diff --git a/src/preload/api/codex-config-sync-bridge.ts b/src/preload/api/codex-config-sync-bridge.ts index e6c8903a698..82eedfaf0ec 100644 --- a/src/preload/api/codex-config-sync-bridge.ts +++ b/src/preload/api/codex-config-sync-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { PreloadApi } from '../api-types' export const codexConfigSyncApi = { status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') -} +} satisfies PreloadApi['codexConfigSync'] diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts index 9dba4b72f82..2575f2bb0e9 100644 --- a/src/preload/api/codex-usage-bridge.ts +++ b/src/preload/api/codex-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage') +export const codexUsageApi = createUsageProviderApi( + ipcRenderer, + 'codexUsage' +) satisfies PreloadApi['codexUsage'] diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts index be441a8682e..bd36efbdcc3 100644 --- a/src/preload/api/computer-use-permissions-bridge.ts +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -1,8 +1,9 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const computerUsePermissionsApi = { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), - openSetup: (args?: { id?: string }): Promise => + getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }) => ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') -} + reset: () => ipcRenderer.invoke('computerUsePermissions:reset') +} satisfies PreloadApi['computerUsePermissions'] diff --git a/src/preload/api/crash-reports-bridge.ts b/src/preload/api/crash-reports-bridge.ts index 19d7044601d..a77ad412eb7 100644 --- a/src/preload/api/crash-reports-bridge.ts +++ b/src/preload/api/crash-reports-bridge.ts @@ -11,6 +11,7 @@ import type { RendererHeapStatistics } from '../../shared/renderer-heap-statisti import type { RendererProcessMemory } from '../../shared/renderer-process-memory' import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader' import { readRendererProcessMemory } from '../renderer-process-memory-reader' +import type { PreloadApi } from '../api-types' export const crashReportsApi = { getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), @@ -28,4 +29,4 @@ export const crashReportsApi = { ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), readProcessMemory: (): Promise => readRendererProcessMemory() -} +} satisfies PreloadApi['crashReports'] diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts index 17241630857..e6862504da9 100644 --- a/src/preload/api/dashboard-bridge.ts +++ b/src/preload/api/dashboard-bridge.ts @@ -5,6 +5,7 @@ import type { DashboardSnapshot, DashboardSpawnAgentArgs } from '../../shared/dashboard-snapshot' +import type { PreloadApi } from '../api-types' export const dashboardApi = { // Open the pop-out dashboard window, or focus it if already open. @@ -71,4 +72,4 @@ export const dashboardApi = { ipcRenderer.invoke('dashboardPopout:spawnAgent', args), sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) -} +} satisfies PreloadApi['dashboard'] diff --git a/src/preload/api/developer-permissions-bridge.ts b/src/preload/api/developer-permissions-bridge.ts index 1aaa51deed3..94158cd7818 100644 --- a/src/preload/api/developer-permissions-bridge.ts +++ b/src/preload/api/developer-permissions-bridge.ts @@ -1,11 +1,11 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const developerPermissionsApi = { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), - request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), + getStatus: () => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }) => ipcRenderer.invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => ipcRenderer.invoke('developerPermissions:openSettings', args), - testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + testLocalNetworkConnection: (args: { host: string; port: number }) => ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) -} +} satisfies PreloadApi['developerPermissions'] diff --git a/src/preload/api/diagnostics-bridge.ts b/src/preload/api/diagnostics-bridge.ts index 6d274f9b08a..bff79fe5817 100644 --- a/src/preload/api/diagnostics-bridge.ts +++ b/src/preload/api/diagnostics-bridge.ts @@ -1,15 +1,16 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const diagnosticsApi = { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), - collectBundle: (lookbackMinutes?: number): Promise => + getStatus: () => ipcRenderer.invoke('diagnostics:getStatus'), + collectBundle: (lookbackMinutes?: number) => ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), openBundlePreview: (bundleSubmissionId: string): Promise => ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), discardBundlePreview: (bundleSubmissionId: string): Promise => ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), - uploadBundle: (bundleSubmissionId: string): Promise => + uploadBundle: (bundleSubmissionId: string) => ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), deleteBundle: (ticketId: string): Promise => ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) -} +} satisfies PreloadApi['diagnostics'] diff --git a/src/preload/api/doc-preview-bridge.ts b/src/preload/api/doc-preview-bridge.ts index 68a099d5ed2..97fbb8da975 100644 --- a/src/preload/api/doc-preview-bridge.ts +++ b/src/preload/api/doc-preview-bridge.ts @@ -8,6 +8,7 @@ import { type DocPreviewFailure } from '../../shared/doc-preview-scheme' import type { DocPreviewGrantRequest } from '../api/doc-preview-api' +import type { PreloadApi } from '../api-types' export const docPreviewApi = { mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => @@ -28,4 +29,4 @@ export const docPreviewApi = { ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) } -} +} satisfies PreloadApi['docPreview'] diff --git a/src/preload/api/e2e-bridge.ts b/src/preload/api/e2e-bridge.ts index 2876b72a265..900b17a9fcf 100644 --- a/src/preload/api/e2e-bridge.ts +++ b/src/preload/api/e2e-bridge.ts @@ -1,5 +1,6 @@ import { preloadE2EConfig } from '../e2e-config' +import type { PreloadApi } from '../api-types' export const e2eApi = { getConfig: () => preloadE2EConfig -} +} satisfies PreloadApi['e2e'] diff --git a/src/preload/api/emulator-bridge.ts b/src/preload/api/emulator-bridge.ts index f13e99fc52a..8ab56471a42 100644 --- a/src/preload/api/emulator-bridge.ts +++ b/src/preload/api/emulator-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const emulatorApi = { startFrameStream: (args: { @@ -95,4 +96,4 @@ export const emulatorApi = { ipcRenderer.on('ui:emulatorAutoAttach', listener) return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) } -} +} satisfies PreloadApi['emulator'] diff --git a/src/preload/api/export-bridge.ts b/src/preload/api/export-bridge.ts index 637d4bea2ef..67ffbfae109 100644 --- a/src/preload/api/export-bridge.ts +++ b/src/preload/api/export-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const exportApi = { htmlToPdf: (args: { @@ -7,4 +8,4 @@ export const exportApi = { }): Promise< { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } > => ipcRenderer.invoke('export:html-to-pdf', args) -} +} satisfies PreloadApi['export'] diff --git a/src/preload/api/feedback-bridge.ts b/src/preload/api/feedback-bridge.ts index 55b3b5fcaa7..4241c5cacf9 100644 --- a/src/preload/api/feedback-bridge.ts +++ b/src/preload/api/feedback-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const feedbackApi = { submit: (args: { @@ -10,4 +11,4 @@ export const feedbackApi = { }): Promise< { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } > => ipcRenderer.invoke('feedback:submit', args) -} +} satisfies PreloadApi['feedback'] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 538480ce2f5..c67e9abd9e3 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -8,6 +8,7 @@ import type { LocalLogTailReadResult, LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' +import type { PreloadApi } from '../api-types' export const fsApi = { readDir: (args: { @@ -216,4 +217,4 @@ export const fsApi = { ipcRenderer.on('fs:changed', listener) return () => ipcRenderer.removeListener('fs:changed', listener) } -} +} satisfies PreloadApi['fs'] diff --git a/src/preload/api/gh-bridge-mutations-and-projects.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts index 3103cb432ec..80b746bfb79 100644 --- a/src/preload/api/gh-bridge-mutations-and-projects.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -35,11 +35,12 @@ import type { UpdateProjectItemFieldArgs } from '../../shared/github/project-request-types' import type { AppStarSource } from '../../shared/gh-star-source' +import type { PreloadApi } from '../api-types' export const ghMutationsAndProjectsApi = { setPRAutoMerge: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number enabled: boolean @@ -49,7 +50,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:setPRAutoMerge', args), updatePRState: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number updates: { state: 'open' | 'closed' } @@ -58,7 +59,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:updatePRState', args), markPRReadyForReview: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -66,7 +67,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:markPRReadyForReview', args), requestPRReviewers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] @@ -75,7 +76,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:requestPRReviewers', args), removePRReviewers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number reviewers: string[] @@ -84,7 +85,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:removePRReviewers', args), updateIssue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number updates: unknown @@ -92,7 +93,7 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:updateIssue', args), addIssueComment: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number body: string @@ -101,7 +102,7 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number commentId: number @@ -113,7 +114,7 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -125,12 +126,12 @@ export const ghMutationsAndProjectsApi = { }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), listLabels: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listLabels', args), listAssignableUsers: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), onWorkItemMutated: ( @@ -199,4 +200,4 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:listIssueTypesBySlug', args), updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) -} +} satisfies Partial diff --git a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts index a4f3e1d45ef..3e4a5f6ce2a 100644 --- a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -9,33 +9,34 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' import type { TaskSourceContext } from '../../shared/task-source-context' +import type { PreloadApi } from '../api-types' export const ghPullRequestsAndWorkItemsApi = { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), - repoSlug: (args: { repoPath: string; repoId?: string }): Promise => + viewer: () => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string; repoId?: string }) => ipcRenderer.invoke('gh:repoSlug', args), - repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + repoUpstream: (args: { repoPath: string; repoId?: string }) => ipcRenderer.invoke('gh:repoUpstream', args), prForBranch: (args: { repoPath: string - repoId?: string + repoId?: string | null branch: string linkedPRNumber?: number | null fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => + }) => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => ipcRenderer.invoke('gh:refreshPRNow', args), enqueuePRRefresh: (args: { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + }) => ipcRenderer.invoke('gh:enqueuePRRefresh', args), reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + }) => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => callback(event) @@ -44,42 +45,42 @@ export const ghPullRequestsAndWorkItemsApi = { }, issue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), + }) => ipcRenderer.invoke('gh:issue', args), workItem: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), + }) => ipcRenderer.invoke('gh:workItem', args), workItemByOwnerRepo: (args: { repoPath: string - repoId?: string + repoId?: string | null owner: string repo: string host?: string number: number type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + }) => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), workItemDetails: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), + }) => ipcRenderer.invoke('gh:workItemDetails', args), notifyWorkItemMutated: (args: { repoPath: string - repoId?: string + repoId?: string | null type: 'issue' | 'pr' number: number }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), prFileContents: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -88,12 +89,12 @@ export const ghPullRequestsAndWorkItemsApi = { status: string headSha: string baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), - listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => + }) => ipcRenderer.invoke('gh:prFileContents', args), + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) => ipcRenderer.invoke('gh:listIssues', args), createIssue: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null title: string body: string @@ -104,7 +105,7 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:countWorkItems', args), listWorkItems: (args: { repoPath: string - repoId?: string + repoId?: string | null limit?: number query?: string page?: number @@ -113,26 +114,26 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:listWorkItems', args), prChecks: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number headSha?: string prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), + }) => ipcRenderer.invoke('gh:prChecks', args), prCheckDetails: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null checkRunId?: number workflowRunId?: number checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + }) => ipcRenderer.invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number headSha?: string @@ -142,15 +143,15 @@ export const ghPullRequestsAndWorkItemsApi = { ipcRenderer.invoke('gh:rerunPRChecks', args), prComments: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), + }) => ipcRenderer.invoke('gh:prComments', args), setPRCommentReaction: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null reactionSubjectId: string content: GitHubReactionContent @@ -159,7 +160,7 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), resolveReviewThread: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null threadId: string resolve: boolean @@ -167,7 +168,7 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number prRepo?: GitHubOwnerRepo | null @@ -177,17 +178,17 @@ export const ghPullRequestsAndWorkItemsApi = { }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), updatePRTitle: (args: { repoPath: string - repoId?: string + repoId?: string | null prNumber: number title: string prRepo?: GitHubOwnerRepo | null }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), mergePR: (args: { repoPath: string - repoId?: string + repoId?: string | null sourceContext?: TaskSourceContext | null prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args) -} +} satisfies Partial diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts index 52c21a966a7..c7da93698e6 100644 --- a/src/preload/api/gh-bridge.ts +++ b/src/preload/api/gh-bridge.ts @@ -1,4 +1,8 @@ +import type { PreloadApi } from '../api-types' import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' -export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi } +export const ghApi = { + ...ghPullRequestsAndWorkItemsApi, + ...ghMutationsAndProjectsApi +} satisfies PreloadApi['gh'] diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts index bd62dfc614a..c2186d12aa3 100644 --- a/src/preload/api/git-bash-bridge.ts +++ b/src/preload/api/git-bash-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const gitBashApi = { isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') -} +} satisfies PreloadApi['gitBash'] diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 89dfc8db5ae..822af96714d 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -3,6 +3,7 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../share import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { PreloadApi } from '../api-types' export const gitApi = { status: (args: { @@ -13,7 +14,7 @@ export const gitApi = { reuseLineStats?: boolean branchLineTotalMergeBase?: string requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), + }) => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => ipcRenderer.invoke('git:cancelStatus', args), setStatusUpstreamRefWatch: (args: { @@ -29,7 +30,7 @@ export const gitApi = { submodulePath: string connectionId?: string area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), + }) => ipcRenderer.invoke('git:submoduleStatus', args), checkIgnored: (args: { worktreePath: string paths: string[] @@ -42,7 +43,7 @@ export const gitApi = { history: ( args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ): Promise => ipcRenderer.invoke('git:history', args), - conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => + conflictOperation: (args: { worktreePath: string; connectionId?: string }) => ipcRenderer.invoke('git:conflictOperation', args), abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:abortMerge', args), @@ -54,17 +55,11 @@ export const gitApi = { staged: boolean compareAgainstHead?: boolean connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchCompare', args), - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), + }) => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { worktreePath: string; baseRef: string; connectionId?: string }) => + ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { worktreePath: string; commitId: string; connectionId?: string }) => + ipcRenderer.invoke('git:commitCompare', args), upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -72,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), @@ -82,6 +78,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:syncFork', args), push: (args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -89,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), @@ -108,7 +107,7 @@ export const gitApi = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), + }) => ipcRenderer.invoke('git:branchDiff', args), commitDiff: (args: { worktreePath: string commitOid: string @@ -116,7 +115,7 @@ export const gitApi = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), + }) => ipcRenderer.invoke('git:commitDiff', args), commit: (args: { worktreePath: string message: string @@ -130,12 +129,12 @@ export const gitApi = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + }) => ipcRenderer.invoke('git:generateCommitMessage', args), discoverCommitMessageModels: (args: { agentId: string worktreePath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + }) => ipcRenderer.invoke('git:discoverCommitMessageModels', args), cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string @@ -154,7 +153,7 @@ export const gitApi = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + }) => ipcRenderer.invoke('git:generatePullRequestFields', args), cancelGeneratePullRequestFields: (args: { worktreePath: string connectionId?: string @@ -197,4 +196,4 @@ export const gitApi = { sha: string connectionId?: string }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) -} +} satisfies PreloadApi['git'] diff --git a/src/preload/api/gl-bridge.ts b/src/preload/api/gl-bridge.ts index c48794a4976..3977536a838 100644 --- a/src/preload/api/gl-bridge.ts +++ b/src/preload/api/gl-bridge.ts @@ -1,3 +1,4 @@ import { glApi } from '../gitlab' +import type { PreloadApi } from '../api-types' -export const glApiBridge = glApi +export const glApiBridge = glApi satisfies PreloadApi['gl'] diff --git a/src/preload/api/grok-accounts-bridge.ts b/src/preload/api/grok-accounts-bridge.ts index b4719905ec7..246fc97bc56 100644 --- a/src/preload/api/grok-accounts-bridge.ts +++ b/src/preload/api/grok-accounts-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' export const grokAccountsApi = { getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') -} +} satisfies PreloadApi['grokAccounts'] diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts index 59a6fee71b6..75c48281b16 100644 --- a/src/preload/api/hooks-bridge.ts +++ b/src/preload/api/hooks-bridge.ts @@ -1,22 +1,14 @@ import { ipcRenderer } from 'electron' import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' export const hooksApi = { - check: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: unknown - mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), + check: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:check', args), - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), createIssueCommandRunner: (args: { repoId: string @@ -41,4 +33,4 @@ export const hooksApi = { content: string hostId?: ExecutionHostId }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) -} +} satisfies PreloadApi['hooks'] diff --git a/src/preload/api/hosted-review-bridge.ts b/src/preload/api/hosted-review-bridge.ts index dc8323b5bf3..b7e0d12af5c 100644 --- a/src/preload/api/hosted-review-bridge.ts +++ b/src/preload/api/hosted-review-bridge.ts @@ -1,12 +1,12 @@ import { ipcRenderer } from 'electron' import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' +import type { PreloadApi } from '../api-types' export const hostedReviewApi = { - forBranch: (args: HostedReviewForBranchArgs): Promise => + forBranch: (args: HostedReviewForBranchArgs) => ipcRenderer.invoke('hostedReview:forBranch', args), - getCreationEligibility: (args: unknown): Promise => + getCreationEligibility: (args: unknown) => ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) -} + create: (args: unknown) => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown) => ipcRenderer.invoke('hostedReview:createStacked', args) +} satisfies PreloadApi['hostedReview'] diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index 47a27b77f68..4b6b991095d 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { JiraProjectStatusOrder } from '../../shared/jira-types' +import type { PreloadApi } from '../api-types' export const jiraApi = { connect: (args: { @@ -7,30 +8,21 @@ export const jiraApi = { email: string apiToken: string authType?: 'cloud' | 'server' - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), + }) => ipcRenderer.invoke('jira:connect', args), disconnect: (args?: { siteId?: string }): Promise => ipcRenderer.invoke('jira:disconnect', args), - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), + selectSite: (args: { siteId: string }) => ipcRenderer.invoke('jira:selectSite', args), - status: (): Promise => ipcRenderer.invoke('jira:status'), + status: () => ipcRenderer.invoke('jira:status'), - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), + readStatus: () => ipcRenderer.invoke('jira:readStatus'), - testConnection: (args?: { - siteId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), + testConnection: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:testConnection', args), - searchIssues: (args: { - jql: string - limit?: number - siteId?: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), + searchIssues: (args: { jql: string; limit?: number; siteId?: string; requestId?: string }) => + ipcRenderer.invoke('jira:searchIssues', args), cancelSearchIssues: (args: { requestId: string }): Promise => ipcRenderer.invoke('jira:cancelSearchIssues', args), @@ -38,16 +30,12 @@ export const jiraApi = { filter?: 'assigned' | 'reported' | 'all' | 'done' limit?: number siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), + }) => ipcRenderer.invoke('jira:listIssues', args), - getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), + getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args), - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), + lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) => + ipcRenderer.invoke('jira:lookupIssueSummary', args), cancelIssueSummary: (args: { requestId: string }): Promise => ipcRenderer.invoke('jira:cancelIssueSummary', args), @@ -75,36 +63,28 @@ export const jiraApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:addIssueComment', args), - issueComments: (args: { key: string; siteId?: string }): Promise => + issueComments: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:issueComments', args), - listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), + listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args), - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => ipcRenderer.invoke('jira:listIssueTypes', args), - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + listCreateFields: (args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => + ipcRenderer.invoke('jira:listCreateFields', args), - listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), + listPriorities: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listPriorities', args), - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), - searchUsers: (args?: { query?: string; siteId?: string }): Promise => + listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) => + ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args?: { query?: string; siteId?: string }) => ipcRenderer.invoke('jira:searchUsers', args), - listTransitions: (args: { key: string; siteId?: string }): Promise => + listTransitions: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:listTransitions', args), getProjectStatusOrder: (args: { projectKey: string siteId?: string }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) -} +} satisfies PreloadApi['jira'] diff --git a/src/preload/api/keybindings-bridge.ts b/src/preload/api/keybindings-bridge.ts index 3111ddd6676..e91e587313d 100644 --- a/src/preload/api/keybindings-bridge.ts +++ b/src/preload/api/keybindings-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' export const keybindingsApi = { get: (): Promise => ipcRenderer.invoke('keybindings:get'), @@ -17,4 +18,4 @@ export const keybindingsApi = { ipcRenderer.on('keybindings:changed', listener) return () => ipcRenderer.removeListener('keybindings:changed', listener) } -} +} satisfies PreloadApi['keybindings'] diff --git a/src/preload/api/linear-bridge.ts b/src/preload/api/linear-bridge.ts index cd6ec0e9c15..8092a8e70e7 100644 --- a/src/preload/api/linear-bridge.ts +++ b/src/preload/api/linear-bridge.ts @@ -1,37 +1,30 @@ import { ipcRenderer } from 'electron' import type { LinearProjectDetail } from '../../shared/linear/project-types' +import type { PreloadApi } from '../api-types' export const linearApi = { - connect: (args: { - apiKey: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), + connect: (args: { apiKey: string }) => ipcRenderer.invoke('linear:connect', args), disconnect: (args?: { workspaceId?: string }): Promise => ipcRenderer.invoke('linear:disconnect', args), - selectWorkspace: (args: { workspaceId: string }): Promise => + selectWorkspace: (args: { workspaceId: string }) => ipcRenderer.invoke('linear:selectWorkspace', args), - status: (): Promise => ipcRenderer.invoke('linear:status'), + status: () => ipcRenderer.invoke('linear:status'), - testConnection: (args?: { - workspaceId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + testConnection: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:testConnection', args), - searchIssues: (args: { - query: string - limit?: number - workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), + searchIssues: (args: { query: string; limit?: number; workspaceId?: string }) => + ipcRenderer.invoke('linear:searchIssues', args), listIssues: (args?: { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number workspaceId?: string attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), + }) => ipcRenderer.invoke('linear:listIssues', args), createIssue: (args: { teamId: string @@ -49,7 +42,7 @@ export const linearApi = { | { ok: false; error: string } > => ipcRenderer.invoke('linear:createIssue', args), - getIssue: (args: { id: string; workspaceId?: string }): Promise => + getIssue: (args: { id: string; workspaceId?: string }) => ipcRenderer.invoke('linear:getIssue', args), updateIssue: (args: { @@ -66,18 +59,17 @@ export const linearApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('linear:addIssueComment', args), - issueComments: (args: { issueId: string; workspaceId?: string }): Promise => + issueComments: (args: { issueId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:issueComments', args), - listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), + listTeams: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:listTeams', args), listProjects: (args?: { query?: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), + }) => ipcRenderer.invoke('linear:listProjects', args), createProject: (args: { name: string @@ -94,7 +86,7 @@ export const linearApi = { }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => ipcRenderer.invoke('linear:createProject', args), - getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => + getProject: (args: { id: string; workspaceId: string; force?: boolean }) => ipcRenderer.invoke('linear:getProject', args), listProjectIssues: (args: { @@ -102,42 +94,38 @@ export const linearApi = { limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + }) => ipcRenderer.invoke('linear:listProjectIssues', args), listCustomViews: (args: { model: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + }) => ipcRenderer.invoke('linear:listCustomViews', args), - getCustomView: (args: { - viewId: string - model: string - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + getCustomView: (args: { viewId: string; model: string; workspaceId: string; force?: boolean }) => + ipcRenderer.invoke('linear:getCustomView', args), listCustomViewIssues: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + }) => ipcRenderer.invoke('linear:listCustomViewIssues', args), listCustomViewProjects: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), + }) => ipcRenderer.invoke('linear:listCustomViewProjects', args), - teamStates: (args: { teamId: string; workspaceId?: string }): Promise => + teamStates: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamStates', args), - teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => + teamLabels: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamLabels', args), - teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => + teamMembers: (args: { teamId: string; workspaceId?: string }) => ipcRenderer.invoke('linear:teamMembers', args) -} +} satisfies PreloadApi['linear'] diff --git a/src/preload/api/macos-tcc-prompts-bridge.ts b/src/preload/api/macos-tcc-prompts-bridge.ts index 0c6c6b184fc..ef24b9e203e 100644 --- a/src/preload/api/macos-tcc-prompts-bridge.ts +++ b/src/preload/api/macos-tcc-prompts-bridge.ts @@ -1,11 +1,14 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const macosTccPromptsApi = { - onThreshold: (callback: (payload: unknown) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => + onThreshold: (callback: (payload: { promptCount: number }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { promptCount: number }): void => callback(payload) ipcRenderer.on('macosTccPrompts:threshold', listener) - return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + return (): void => { + ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + } }, consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => ipcRenderer.invoke('macosTccPrompts:consumePending'), @@ -14,4 +17,4 @@ export const macosTccPromptsApi = { releasePending: (claimId: number): Promise => ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') -} +} satisfies PreloadApi['macosTccPrompts'] diff --git a/src/preload/api/memory-bridge.ts b/src/preload/api/memory-bridge.ts index 15af55cb09c..c1735f86e31 100644 --- a/src/preload/api/memory-bridge.ts +++ b/src/preload/api/memory-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { MemorySnapshot } from '../../shared/process-stats-types' +import type { PreloadApi } from '../api-types' export const memoryApi = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') -} +} satisfies PreloadApi['memory'] diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts index a758d9e2e5b..e99bd843909 100644 --- a/src/preload/api/minimax-credentials-bridge.ts +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const minimaxCredentialsApi = { getStatus: (): Promise<{ configured: boolean }> => @@ -7,4 +8,4 @@ export const minimaxCredentialsApi = { ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), clearCookie: (): Promise<{ configured: boolean }> => ipcRenderer.invoke('minimaxCredentials:clearCookie') -} +} satisfies PreloadApi['minimaxCredentials'] diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts index 836f27f6f37..a1ad9a4c716 100644 --- a/src/preload/api/mobile-bridge.ts +++ b/src/preload/api/mobile-bridge.ts @@ -3,6 +3,7 @@ import type { MobileRelayStatus } from '../../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' +import type { PreloadApi } from '../api-types' export const mobileApi = { listNetworkInterfaces: (): Promise<{ @@ -92,4 +93,4 @@ export const mobileApi = { ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) } -} +} satisfies PreloadApi['mobile'] diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts index 16c2906349e..3a0a5d9161a 100644 --- a/src/preload/api/native-chat-bridge.ts +++ b/src/preload/api/native-chat-bridge.ts @@ -2,7 +2,8 @@ import { ipcRenderer } from 'electron' import type { NativeChatAppendedPayload, NativeChatReadSessionResult, - NativeChatSubscriptionFrame + NativeChatSubscriptionFrame, + PreloadApi } from '../api-types' import type { AgentType } from '../../shared/native-chat-types' @@ -37,4 +38,4 @@ export const nativeChatApi = { ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) } } -} +} satisfies PreloadApi['nativeChat'] diff --git a/src/preload/api/notebook-bridge.ts b/src/preload/api/notebook-bridge.ts index ee307b9f0e2..436726b7783 100644 --- a/src/preload/api/notebook-bridge.ts +++ b/src/preload/api/notebook-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const notebookApi = { runPythonCell: (args: { @@ -8,4 +9,4 @@ export const notebookApi = { connectionId?: string | null }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => ipcRenderer.invoke('notebook:runPythonCell', args) -} +} satisfies PreloadApi['notebook'] diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts index aa84fb1a8a6..70c64d4ce0d 100644 --- a/src/preload/api/notifications-bridge.ts +++ b/src/preload/api/notifications-bridge.ts @@ -8,6 +8,7 @@ import type { NotificationSoundPathResult, NotificationSoundResult } from '../../shared/notification-settings-types' +import type { PreloadApi } from '../api-types' // Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. let cachedNotificationSound: { @@ -117,4 +118,4 @@ export const notificationsApi = { return { played: false, reason: 'playback-failed' } } } -} +} satisfies PreloadApi['notifications'] diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts index 1b4393268ac..7939ab64810 100644 --- a/src/preload/api/onboarding-bridge.ts +++ b/src/preload/api/onboarding-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { PreloadApi } from '../api-types' export const onboardingApi = { get: (): Promise => ipcRenderer.invoke('onboarding:get'), @@ -8,4 +9,4 @@ export const onboardingApi = { checklist?: Partial } ): Promise => ipcRenderer.invoke('onboarding:update', updates) -} +} satisfies PreloadApi['onboarding'] diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts index 5cc668e6e00..cd3564d2b32 100644 --- a/src/preload/api/open-code-usage-bridge.ts +++ b/src/preload/api/open-code-usage-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' -export const openCodeUsageApi = createUsageProviderApi(ipcRenderer, 'openCodeUsage') +export const openCodeUsageApi = createUsageProviderApi( + ipcRenderer, + 'openCodeUsage' +) satisfies PreloadApi['openCodeUsage'] diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts index 8a5d3309c5c..c51751b3161 100644 --- a/src/preload/api/pet-bridge.ts +++ b/src/preload/api/pet-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { CustomPet } from '../../shared/pet-types' +import type { PreloadApi } from '../api-types' export const petApi = { import: (): Promise => ipcRenderer.invoke('pet:import'), @@ -8,4 +9,4 @@ export const petApi = { ipcRenderer.invoke('pet:read', id, fileName, kind), delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => ipcRenderer.invoke('pet:delete', id, fileName, kind) -} +} satisfies PreloadApi['pet'] diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 2488d4cfdb7..0e529e74d96 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -24,11 +24,8 @@ export const pluginsApi = { pluginKey: string panelId: string }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + invokeCommand: (args: { pluginKey: string; commandId: string; args?: unknown }) => + ipcRenderer.invoke('plugins:invokeCommand', args), panelAction: (args: { sessionToken: string action: string diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts index c05721a2d3b..64d317d139c 100644 --- a/src/preload/api/preflight-bridge.ts +++ b/src/preload/api/preflight-bridge.ts @@ -1,5 +1,5 @@ import { ipcRenderer } from 'electron' -import type { PreflightRuntimeContext, RefreshAgentsResult } from '../api-types' +import type { PreflightRuntimeContext, PreloadApi, RefreshAgentsResult } from '../api-types' export const preflightApi = { check: (args?: { @@ -40,4 +40,4 @@ export const preflightApi = { gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) -} +} satisfies PreloadApi['preflight'] diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 7e761738256..ef6002e11c8 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -15,6 +15,7 @@ import type { import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' +import type { PreloadApi } from '../api-types' export const ptySessionControlApi = { spawn: (opts: { @@ -204,4 +205,4 @@ export const ptySessionControlApi = { ipcRenderer.invoke('pty:hasChildProcesses', { id }), getForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:getForegroundProcess', { id }) -} +} satisfies Partial diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 8fc9c48bce1..f751491c0e0 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { PreloadApi } from '../api-types' export const ptyStreamAndSerializationApi = { inspectProcess: ( @@ -138,4 +139,4 @@ export const ptyStreamAndSerializationApi = { restart: () => ipcRenderer.invoke('pty:management:restart'), macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') } -} +} satisfies Partial diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts index df080692178..18f867e6426 100644 --- a/src/preload/api/pty-bridge.ts +++ b/src/preload/api/pty-bridge.ts @@ -1,4 +1,8 @@ +import type { PreloadApi } from '../api-types' import { ptySessionControlApi } from './pty-bridge-session-control' import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' -export const ptyApi = { ...ptySessionControlApi, ...ptyStreamAndSerializationApi } +export const ptyApi = { + ...ptySessionControlApi, + ...ptyStreamAndSerializationApi +} satisfies PreloadApi['pty'] diff --git a/src/preload/api/pwsh-bridge.ts b/src/preload/api/pwsh-bridge.ts index bd202277ad1..34ed9880ada 100644 --- a/src/preload/api/pwsh-bridge.ts +++ b/src/preload/api/pwsh-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const pwshApi = { isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') -} +} satisfies PreloadApi['pwsh'] diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts index f403d79cdc6..37af13f0006 100644 --- a/src/preload/api/rate-limits-bridge.ts +++ b/src/preload/api/rate-limits-bridge.ts @@ -4,6 +4,7 @@ import type { RateLimitRuntimeTarget, RateLimitState } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' export const rateLimitsApi = { get: (): Promise => ipcRenderer.invoke('rateLimits:get'), @@ -27,4 +28,4 @@ export const rateLimitsApi = { ipcRenderer.on('rateLimits:update', listener) return () => ipcRenderer.removeListener('rateLimits:update', listener) } -} +} satisfies PreloadApi['rateLimits'] diff --git a/src/preload/api/runtime-bridge.ts b/src/preload/api/runtime-bridge.ts index c58049bcbe3..8b31e6873f5 100644 --- a/src/preload/api/runtime-bridge.ts +++ b/src/preload/api/runtime-bridge.ts @@ -9,6 +9,7 @@ import type { } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' export const runtimeApi = { syncWindowGraph: (graph: RuntimeRendererSyncWindowGraph): Promise => @@ -141,4 +142,4 @@ export const runtimeApi = { ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) } -} +} satisfies PreloadApi['runtime'] diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index d018c0574c8..ddfa498dc74 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -9,6 +9,7 @@ import { subscribeRuntimeEnvironmentFromPreload, type RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' export const runtimeEnvironmentsApi = { list: (): Promise => @@ -90,4 +91,4 @@ export const runtimeEnvironmentsApi = { } ): Promise => subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) -} +} satisfies PreloadApi['runtimeEnvironments'] diff --git a/src/preload/api/settings-bridge.ts b/src/preload/api/settings-bridge.ts index d8aaa23b0b7..4e7105c00cb 100644 --- a/src/preload/api/settings-bridge.ts +++ b/src/preload/api/settings-bridge.ts @@ -4,22 +4,20 @@ import type { WarpThemeImportPreview, WarpThemeImportSource } from '../../shared/terminal-custom-themes' +import type { PreloadApi } from '../api-types' export const settingsApi = { - get: (): Promise => ipcRenderer.invoke('settings:get'), + get: () => ipcRenderer.invoke('settings:get'), // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. - getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + getSync: () => ipcRenderer.sendSync('settings:get-sync'), - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), + set: (args: Record) => ipcRenderer.invoke('settings:set', args), - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }): Promise => + setActiveRuntimeEnvironmentPreference: (args: { environmentId: string | null }) => ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => ipcRenderer.invoke('settings:update-pr-bot-author-override', args), listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), @@ -36,4 +34,4 @@ export const settingsApi = { ipcRenderer.on('settings:changed', listener) return () => ipcRenderer.removeListener('settings:changed', listener) } -} +} satisfies PreloadApi['settings'] diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index be34c7ff70e..21ccda3fd83 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -4,6 +4,7 @@ import type { ShellOpenExternalEditorResult, ShellOpenLocalPathResult } from '../../shared/shell-open-types' +import type { PreloadApi } from '../api-types' export const shellApi = { openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), @@ -38,4 +39,4 @@ export const shellApi = { copyFile: (args: { srcPath: string; destPath: string }): Promise => ipcRenderer.invoke('shell:copyFile', args) -} +} satisfies PreloadApi['shell'] diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts index eafaf2ce543..4bcde9a613c 100644 --- a/src/preload/api/skills-bridge.ts +++ b/src/preload/api/skills-bridge.ts @@ -37,6 +37,7 @@ import type { SkillUpdateRun, SkillUpdateStartResult } from '../../shared/skill-freshness' +import type { PreloadApi } from '../api-types' export const skillsApi = { discover: (target?: SkillDiscoveryTarget): Promise => @@ -126,4 +127,4 @@ export const skillsApi = { ipcRenderer.on('skills:updateRun', listener) return () => ipcRenderer.removeListener('skills:updateRun', listener) } -} +} satisfies PreloadApi['skills'] diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts index 513b219f498..dbd7e0d26ab 100644 --- a/src/preload/api/speech-bridge.ts +++ b/src/preload/api/speech-bridge.ts @@ -6,6 +6,7 @@ import type { SpeechModelState, SpeechTranscriptEvent } from '../../shared/speech-types' +import type { PreloadApi } from '../api-types' export const speechApi = { getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), @@ -78,4 +79,4 @@ export const speechApi = { ipcRenderer.on('speech:error', listener) return () => ipcRenderer.removeListener('speech:error', listener) } -} +} satisfies PreloadApi['speech'] 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 2884e4ec8c1..d552fb1781d 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -10,13 +10,15 @@ import type { SshTarget, SshTargetUpdateInput, PortForwardEntry, - EnrichedDetectedPort + EnrichedDetectedPort, + SshTerminateSessionsResult } from '../../shared/ssh-types' import { admitSshConnectionStateForAuthorityReconciliation, admitSshDetectedPorts } from '../../shared/ssh-retained-payload-admission' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { PreloadApi } from '../api-types' export const sshApi = { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), @@ -50,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 => @@ -180,4 +182,4 @@ export const sshApi = { submitCredential: (args: { requestId: string; value: string | null }): Promise => ipcRenderer.invoke('ssh:submitCredential', args) -} +} satisfies PreloadApi['ssh'] diff --git a/src/preload/api/star-nag-bridge.ts b/src/preload/api/star-nag-bridge.ts index b697739a52f..49e56e4aa91 100644 --- a/src/preload/api/star-nag-bridge.ts +++ b/src/preload/api/star-nag-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const starNagApi = { onShow: ( @@ -27,4 +28,4 @@ export const starNagApi = { ipcRenderer.invoke('star-nag:agentValueMoment'), showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') -} +} satisfies PreloadApi['starNag'] diff --git a/src/preload/api/stats-bridge.ts b/src/preload/api/stats-bridge.ts index 20bc823b86a..f435b9980f5 100644 --- a/src/preload/api/stats-bridge.ts +++ b/src/preload/api/stats-bridge.ts @@ -1,4 +1,5 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const statsApi = { getSummary: (): Promise<{ @@ -7,4 +8,4 @@ export const statsApi = { totalAgentTimeMs: number firstEventAt: number | null }> => ipcRenderer.invoke('stats:summary') -} +} satisfies PreloadApi['stats'] diff --git a/src/preload/api/terminal-preview-bridge.ts b/src/preload/api/terminal-preview-bridge.ts index c8bf5b38623..3bd94d4998b 100644 --- a/src/preload/api/terminal-preview-bridge.ts +++ b/src/preload/api/terminal-preview-bridge.ts @@ -3,6 +3,7 @@ import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload } from '../../shared/terminal-preview' +import type { PreloadApi } from '../api-types' export const terminalPreviewApi = { connect: ( @@ -30,4 +31,4 @@ export const terminalPreviewApi = { ipcRenderer.on('terminalPreview:data', listener) return () => ipcRenderer.removeListener('terminalPreview:data', listener) } -} +} satisfies PreloadApi['terminalPreview'] 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 867bd80026d..fdad19c2944 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -12,6 +12,7 @@ import { import type { NativeFileDropPayload } from '../../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import { subscribeNativeFileDrop } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const uiClipboardAndWindowControlsApi = { onOpenDiffFromMobile: ( @@ -195,4 +196,4 @@ export const uiClipboardAndWindowControlsApi = { notifyWindowRevealed: (): void => { ipcRenderer.send('ui:window-revealed') } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index 25476165cfb..246cebb1613 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -1,6 +1,8 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import { ipcRenderer } from 'electron' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { KeybindingActionId } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' export const uiStateAndMenuCommandsApi = { get: () => ipcRenderer.invoke('ui:get'), @@ -26,6 +28,14 @@ export const uiStateAndMenuCommandsApi = { }, consumePendingSkillShare: (): Promise => ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, documents: MarkdownDocument[]): void => + callback(documents) + ipcRenderer.on('ui:openMarkdownFiles', listener) + return () => ipcRenderer.removeListener('ui:openMarkdownFiles', listener) + }, + consumePendingMarkdownFileOpens: (): Promise => + ipcRenderer.invoke('ui:consumePendingMarkdownFileOpens'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) @@ -164,4 +174,4 @@ export const uiStateAndMenuCommandsApi = { replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { ipcRenderer.send('browser:tabCreateReply', reply) } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts index ccca9a24f5b..d275367b398 100644 --- a/src/preload/api/ui-bridge-tab-and-browser-commands.ts +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -6,6 +6,7 @@ import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' import { browserFindSubscriptions } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' export const uiTabAndBrowserCommandsApi = { onRequestTabSetProfile: ( @@ -200,4 +201,4 @@ export const uiTabAndBrowserCommandsApi = { ipcRenderer.on('ui:activateWorktree', listener) return () => ipcRenderer.removeListener('ui:activateWorktree', listener) } -} +} satisfies Partial diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index 9de47cceb0c..eaff9e8847a 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -11,6 +11,7 @@ import type { RuntimeTerminalCreateRequestPayload, RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { PreloadApi } from '../api-types' export const uiTerminalAndSessionTabsApi = { onCreateTerminal: ( @@ -211,4 +212,4 @@ export const uiTerminalAndSessionTabsApi = { ipcRenderer.on('ui:openFileFromMobile', listener) return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) } -} +} satisfies Partial diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index e63034b5233..0876104e471 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,3 +1,4 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' import type { @@ -48,6 +49,10 @@ export type UiCommandEventApi = { consumePendingOpenSettings: () => Promise onOpenSkillShare: (callback: (shareId: string) => void) => () => void consumePendingSkillShare: () => Promise + /** OS "Open With" markdown paths pushed while a renderer is already listening. */ + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void + /** Drains the "Open With" paths queued before this renderer's listener attached. */ + consumePendingMarkdownFileOpens: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/api/wsl-bridge.ts b/src/preload/api/wsl-bridge.ts index aeb32cdfa45..bc7869000e4 100644 --- a/src/preload/api/wsl-bridge.ts +++ b/src/preload/api/wsl-bridge.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' export const wslApi = { isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') -} +} satisfies PreloadApi['wsl'] diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 19eb6948c9a..d794a86b9ab 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -92,6 +92,20 @@ describe('native preload destructive app actions', () => { }) } + it('exposes the durable checkpoint join the lazy-chunk recovery reload depends on', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ ok: true }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).resolves.toBeUndefined() + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + + invoke.mockResolvedValue({ ok: false }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + }) + it('preserves both macOS keyboard preload adapters', async () => { const api = await loadApi() invoke.mockResolvedValue(undefined) diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index d6f12367db0..154e139e4c4 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -12,23 +12,21 @@ type GitLabRepoSelectorArgs = { } export const glApi = { - viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gitlab:diagnoseAuth'), - rateLimit: (args?: { force?: boolean; host?: string | null }): Promise => + viewer: () => ipcRenderer.invoke('gitlab:viewer'), + diagnoseAuth: () => ipcRenderer.invoke('gitlab:diagnoseAuth'), + rateLimit: (args?: { force?: boolean; host?: string | null }) => ipcRenderer.invoke('gitlab:rateLimit', args), - projectSlug: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:projectSlug', args), + projectSlug: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:projectSlug', args), mrForBranch: ( args: GitLabRepoSelectorArgs & { branch: string linkedMRIid?: number | null } - ): Promise => ipcRenderer.invoke('gitlab:mrForBranch', args), + ) => ipcRenderer.invoke('gitlab:mrForBranch', args), - mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise => - ipcRenderer.invoke('gitlab:mr', args), + mr: (args: GitLabRepoSelectorArgs & { iid: number }) => ipcRenderer.invoke('gitlab:mr', args), listMRs: ( args: GitLabRepoSelectorArgs & { @@ -37,7 +35,7 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listMRs', args), + ) => ipcRenderer.invoke('gitlab:listMRs', args), listWorkItems: ( args: GitLabRepoSelectorArgs & { @@ -46,9 +44,9 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listWorkItems', args), + ) => ipcRenderer.invoke('gitlab:listWorkItems', args), - issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise => + issue: (args: GitLabRepoSelectorArgs & { number: number }) => ipcRenderer.invoke('gitlab:issue', args), listIssues: ( @@ -58,8 +56,7 @@ export const glApi = { limit?: number page?: number } - ): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> => - ipcRenderer.invoke('gitlab:listIssues', args), + ) => ipcRenderer.invoke('gitlab:listIssues', args), createIssue: ( args: GitLabRepoSelectorArgs & { @@ -77,25 +74,23 @@ export const glApi = { ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:updateIssue', args), - addIssueComment: ( - args: GitLabRepoSelectorArgs & { number: number; body: string } - ): Promise => ipcRenderer.invoke('gitlab:addIssueComment', args), + addIssueComment: (args: GitLabRepoSelectorArgs & { number: number; body: string }) => + ipcRenderer.invoke('gitlab:addIssueComment', args), listLabels: (args: GitLabRepoSelectorArgs): Promise => ipcRenderer.invoke('gitlab:listLabels', args), - listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise => + listAssignableUsers: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:listAssignableUsers', args), - todos: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:todos', args), + todos: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:todos', args), workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemDetails', args), + ) => ipcRenderer.invoke('gitlab:workItemDetails', args), closeMR: ( args: GitLabRepoSelectorArgs & { @@ -133,9 +128,9 @@ export const glApi = { reviewerIds: number[] projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + ) => ipcRenderer.invoke('gitlab:updateMRReviewers', args), - addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise => + addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }) => ipcRenderer.invoke('gitlab:addMRComment', args), addMRInlineComment: ( @@ -144,7 +139,7 @@ export const glApi = { input: unknown projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + ) => ipcRenderer.invoke('gitlab:addMRInlineComment', args), resolveMRDiscussion: ( args: GitLabRepoSelectorArgs & { @@ -152,15 +147,14 @@ export const glApi = { discussionId: string resolved: boolean } - ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + ) => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } - ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), + ) => ipcRenderer.invoke('gitlab:jobTrace', args), - retryJob: ( - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:retryJob', args), + retryJob: (args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }) => + ipcRenderer.invoke('gitlab:retryJob', args), workItemByPath: ( args: GitLabRepoSelectorArgs & { @@ -169,5 +163,5 @@ export const glApi = { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemByPath', args) + ) => ipcRenderer.invoke('gitlab:workItemByPath', args) } diff --git a/src/preload/index.ts b/src/preload/index.ts index ad97a911fe5..27d3ca8e062 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,7 +182,7 @@ const api = { mobile: mobileApi, agentStatus: agentStatusApi, speech: speechApi -} +} satisfies PreloadApi if (process.contextIsolated) { try { @@ -193,6 +193,5 @@ if (process.contextIsolated) { } } else { window.electron = electronAPI - // @ts-expect-error (define in dts) window.api = api } 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-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/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/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 584cd9a9c9a..2b5272f440d 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -6,7 +6,7 @@ import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-worksp import { useStartupActions } from './use-app-startup-actions' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' -import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -189,7 +189,9 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta timeRendererStartupSyncStep('hydrate-session-stores', () => { actions.hydrateWorkspaceSession(sessionRead.session, { ...sessionHydrationOptions, - runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, + contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, + contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey }) actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) 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({