diff --git a/.gitattributes b/.gitattributes index 2a99890023b..8f4f884295d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,7 +8,19 @@ /src/cli/bundled-skill-guides.ts text eol=lf # Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash. /resources/plugins/** text eol=lf -# pnpm hashes every patch byte-for-byte, so a CRLF checkout breaks the install. +# Relay assets are copied verbatim into the bundle and hashed byte-for-byte into +# .version, which names the immutable remote install dir. A CRLF checkout makes a +# Windows-built client disagree with a mac/Linux-built one on the same release, +# so one host ends up with two relay trees (#17886 review). +/config/relay-assets/** text eol=lf +# Pin the bytes so a patch reads and diffs identically on every host. It is NOT +# what makes the hash right: pnpm hashes a patch LF-normalized, so a CRLF checkout +# cannot change it. Believing otherwise put a hand-computed raw digest in the +# lockfile twice and broke every install (#17886). +# These files are stored LF, which is not always the encoding they were written +# against -- @vscode/windows-process-tree ships CRLF sources -- so any code that +# runs `git apply` on one must force `-c core.autocrlf=input` rather than trust +# the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs. /config/patches/*.patch -text # The xterm bundle hunks also make a diff nobody can read; review the hand-written # source patch under xterm-src/ instead. The sibling patches stay diffable. diff --git a/.github/scripts/e2e-with-window-manager.sh b/.github/scripts/e2e-with-window-manager.sh new file mode 100644 index 00000000000..d431a039809 --- /dev/null +++ b/.github/scripts/e2e-with-window-manager.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +openbox --sm-disable > /tmp/orca-e2e-window-manager.log 2>&1 & +wm_pid=$! +cleanup() { + kill "$wm_pid" 2>/dev/null || true + wait "$wm_pid" 2>/dev/null || true +} +trap cleanup EXIT +ready=false +for attempt in {1..100}; do + if xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | rg -q 'window id # 0x[1-9a-fA-F]'; then + ready=true + break + fi + if ! kill -0 "$wm_pid" 2>/dev/null; then + cat /tmp/orca-e2e-window-manager.log + exit 1 + fi + sleep 0.1 +done +if [ "$ready" != true ]; then + echo 'Window manager did not acquire the Xvfb root window' >&2 + exit 1 +fi +"$@" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3560d302a79..a94a7ea2ba5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,10 @@ on: description: Ref to check out (defaults to the workflow ref) required: false type: string + test_files: + description: JSON array of specs to run; empty runs the full suite + required: false + type: string schedule: # Why: GitHub cron uses UTC; these slots map to 10am and 3pm # America/Phoenix for the default-branch E2E run. @@ -146,7 +150,7 @@ jobs: # Native cache misses need the compiler, Electron needs Xvfb, and paired # Quick Open needs ripgrep. Install them in one apt transaction per shard. - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -167,7 +171,7 @@ jobs: # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without @@ -201,7 +205,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -241,7 +245,7 @@ jobs: if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then E2E_PROJECT_ARGS+=(--project=electron-headful) fi - xvfb-run --auto-servernum env "${E2E_ENV[@]}" \ + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \ pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - name: Upload Playwright traces @@ -278,7 +282,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -293,7 +297,7 @@ jobs: # Why: this is the release-path proof that the deployed Linux relay keeps # its PTY and explorer live across a real watcher SIGSEGV. - name: Run Docker SSH watcher isolation E2E - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation # Why: Playwright empties test-results/ when it starts, so each step here used to # destroy the previous step's traces. Only the last lane's failure was ever @@ -310,7 +314,7 @@ jobs: # readiness across live SSH, headed paired, and headless serve topologies. - name: Run Docker SSH terminal parking + startup readiness E2E if: always() - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - name: Keep terminal-parking traces if: always() @@ -326,7 +330,7 @@ jobs: # legible as an SSH-named failure. - name: Run remaining Docker SSH E2E if: always() - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker - name: Keep remaining-ssh-docker traces if: always() diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0e2fa3f273c..bd655eb801a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -832,10 +832,13 @@ jobs: node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + # vitest runs here directly rather than through `pnpm test`, so the addon + # assertions only hold once install-node-dependencies has rebuilt natives. - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts config/scripts/rebuild-native-deps.test.mjs + config/scripts/rebuild-native-deps-windows-process-tree.test.mjs src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts src/main/browser/browser-route-tcp-egress.electron.test.ts src/main/browser/browser-route-webrtc-egress.electron.test.ts @@ -844,10 +847,13 @@ jobs: src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts + src/shared/child-process/windows-cmd-shim-resolution.test.ts + src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts src/main/windows/windows-pty-job.win32.test.ts src/main/windows/windows-host-job.win32.test.ts + src/main/windows/windows-process-tree-command-line-patch.test.ts src/main/windows-live-tree-kill.win32.test.ts src/main/wsl/wsl-runner.test.ts src/main/wsl/wsl-guest-environment.test.ts @@ -856,14 +862,18 @@ jobs: src/main/wsl/wsl-w1-w3-contract.test.ts src/shared/source-scan/source-tree-scan.test.ts src/main/cli/wsl-cli-powershell-boundary.test.ts + src/main/computer/desktop-script-runtime-host.win32.test.ts src/main/cursor/hook-service.test.ts src/main/orca-profiles/profile-index-store.test.ts src/main/startup/windows-install-dir-acl-repair.win32.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts src/shared/secure-file-fsync-flags.test.ts + src/shared/secure-path-windows-acl.win32.test.ts + src/main/runtime/unreadable-secret-store-preservation.win32.test.ts src/main/ipc/pty-codex-account-attribution.test.ts src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts + src/relay/windows-port-scan.win32.test.ts # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 001eee4e03c..c2124d12990 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -809,13 +809,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.cut.outputs.tag }} - run: | - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Release $TAG already exists." - exit 0 - fi - - node config/scripts/create-draft-release.mjs "$TAG" + run: node config/scripts/create-draft-release.mjs "$TAG" terminal-rendering-golden: needs: cut @@ -1427,6 +1421,17 @@ jobs: command: ${{ matrix.release_command }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Why: the NSIS uninstaller only exists inside electron-builder's + # uninstaller pass, which deletes it right after embedding it. The sign + # hook in config/scripts/windows-uninstaller-signing.cjs copies it out + # here so it can ride the inner-binaries SignPath request below. + # Why runner.temp and never the workspace: `files` in + # config/electron-builder.config.cjs is all-negation, so app-builder + # prepends `**/*` and packs whatever is left in the checkout root. This + # step retries up to 3 times; attempt 1 writes the file after packing, + # but attempts 2 and 3 would then pack the unsigned uninstaller into + # app.asar - the exact defect this chain exists to remove. + ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe - name: Verify Windows node-pty ConPTY runtime if: matrix.platform == 'win' && github.run_attempt == 1 @@ -1453,7 +1458,10 @@ jobs: # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE # files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip # request, then the installer is rebuilt from the signed tree before the - # existing installer signing request below. Every step in this chain is + # existing installer signing request below. The NSIS uninstaller rides + # this same request (it is the MDE update cluster: old-uninstaller.exe / + # Uninstall Orca.exe), captured through electron-builder's sign hook and + # swapped back in during the rebuild — no third approval wait. Every step is # fail-open (continue-on-error + outcome gating): any failure ships the # original installer with unsigned inner binaries, exactly like releases # did before this chain existed. Rehearsed end to end in run 28988432001 @@ -1500,6 +1508,36 @@ jobs: Write-Host "Skipped $($skipped.Count) already-signed files:" $skipped | ForEach-Object { Write-Host " $_" } + # Why the uninstaller rides this request: it is the file MDE flagged in + # the whole update cluster (old-uninstaller.exe / Uninstall Orca.exe), + # and folding it in here costs no extra approval wait. Why it is kept + # out of inner-signing-list.txt: that list drives the copy-back into + # dist/win-unpacked, and the uninstaller does not live there — it is + # re-injected through the sign hook during the rebuild instead. + # Why this name and not "Uninstall Orca.exe": the restore loop below + # matches staged files by suffix (`-like "*$relative"`) and takes the + # first hit, so any staged path ending in "Orca.exe" is separated from + # the real Orca.exe only by Get-ChildItem's enumeration order. That + # order happens to favour the root file today, but it is not a + # documented guarantee; a name that cannot suffix-match is. + # Why the whole block is caught rather than just Test-Path'd: this + # step's outcome gates the upload of every inner binary, so a locked + # file or a full disk here would cost all of them their signatures - + # worse than shipping no uninstaller signature at all. + try { + $exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe' + if (Test-Path -LiteralPath $exportedUninstaller) { + $uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe' + New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) -ErrorAction Stop | Out-Null + Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force -ErrorAction Stop + Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe' + } else { + Write-Host "::warning::No exported NSIS uninstaller at $exportedUninstaller; this release ships an unsigned uninstaller (fail-open)." + } + } catch { + Write-Host "::warning::Could not stage the NSIS uninstaller ($_); this release ships an unsigned uninstaller (fail-open)." + } + - name: Upload unsigned inner binaries for SignPath id: upload-unsigned-inner if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success' @@ -1644,6 +1682,31 @@ jobs: throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } + # Why gated separately from the inner restore above: if SignPath's + # windows-inner-binaries-zip artifact configuration does not (yet) cover the + # uninstaller/ directory, the uninstaller comes back missing. That must cost + # only the uninstaller signature — the rebuild below still runs and still + # ships the signed inner binaries, exactly as it does today. + - name: Restore signed uninstaller for the installer rebuild + id: restore-signed-uninstaller + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' + continue-on-error: true + shell: pwsh + run: | + $signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' | + Select-Object -First 1 + if ($null -eq $signed) { + throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the windows-inner-binaries-zip artifact configuration covers it.' + } + $signature = Get-AuthenticodeSignature -FilePath $signed.FullName + if ($null -eq $signature.SignerCertificate) { + throw 'The returned NSIS uninstaller carries no signature.' + } + $signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed' + New-Item -ItemType Directory -Force -Path $signedDir | Out-Null + Copy-Item -LiteralPath $signed.FullName -Destination (Join-Path $signedDir 'orca-uninstaller.exe') -Force + Write-Host ("{0,-14} uninstaller <{1}>" -f $signature.Status, $signature.SignerCertificate.Subject) + # Why this step exists: electron-builder's CopyElevateHelper re-copies a # pristine elevate.exe from its download cache over resources\elevate.exe # on EVERY nsis pack — including the --prepackaged rebuild below — which @@ -1653,9 +1716,12 @@ jobs: # no-op. Known quirk: the cache persists across releases via actions/cache, # so later runs may see elevate.exe as already signed and skip staging it — # that is fine (the signature is timestamped) and the evidence gate checks - # elevate.exe in the shipped installer unconditionally. If this ever causes - # trouble, delete this step; the only effect is elevate.exe shipping - # unsigned again, which the evidence gate will flag. + # elevate.exe in the shipped installer unconditionally. + # + # The cache lookup lives in a script because the inline path this step used + # (`\nsis`) matches no app-builder-lib layout, and `SilentlyContinue` + # plus `exit 0` turned that miss into a green step — v1.4.193 and v1.4.194 + # shipped an unsigned elevate.exe that way. A miss now fails the step. - name: Replace cached elevate.exe with the signed copy id: sign-elevate-cache if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' @@ -1667,20 +1733,26 @@ jobs: Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.' exit 0 } + # Why this guard stays: windows-signing-rehearsal.yml shares the + # electron-builder-win- cache key with this workflow, so a + # test-certificate elevate.exe must never be staged into a release cache. $signature = Get-AuthenticodeSignature -FilePath $signed $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap." exit 0 } - $cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue) - if ($cached.Count -eq 0) { - Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.' - exit 0 - } - foreach ($file in $cached) { - Copy-Item -Path $signed -Destination $file.FullName -Force - Write-Host "Replaced $($file.FullName) with the SignPath-signed copy." + node config/scripts/replace-cached-nsis-elevate.mjs $signed + if ($LASTEXITCODE -ne 0) { + $message = 'Cached elevate.exe swap found nothing to replace; the rebuilt installer ships an unsigned UAC elevation helper (issue #7785).' + if ($env:GITHUB_STEP_SUMMARY) { + try { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows elevate.exe cache swap:** FAILED — $message" -ErrorAction Stop + } catch { + Write-Host "::warning::Could not write the elevate.exe swap verdict to the job summary: $_" + } + } + throw $message } - name: Rebuild NSIS installer from signed unpacked app @@ -1688,6 +1760,11 @@ jobs: if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' continue-on-error: true shell: pwsh + env: + # Why unconditional: the sign hook keys off the file existing, which it + # only does when the restore step above succeeded. A missing file logs a + # warning and embeds the freshly built unsigned uninstaller instead. + ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe run: | # Why: keep the pre-rebuild artifacts so a failed rebuild can fall # back to shipping them unchanged (fail-open). @@ -1879,6 +1956,7 @@ jobs: env: ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false' INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }} + UNINSTALLER_SIGNING_COMPLETED: ${{ steps.restore-signed-uninstaller.outcome == 'success' }} run: | $required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true' @@ -1959,6 +2037,39 @@ jobs: if ($targets -notcontains 'resources\elevate.exe') { $targets += 'resources\elevate.exe' } + # Why the uninstaller is not in $targets: NSIS embeds it in its own + # compressed data section (`File /oname=${UNINSTALL_FILENAME}` in + # app-builder-lib templates/nsis/include/installer.nsh), not in the + # app 7z payload extracted above - the bundled 7za cannot see it. + # What the receipt proves and does not: the digest comparison is + # equal by construction (the hook digests the bytes it copied from + # this same file), so the real signal is that the receipt exists at + # all - the import leg ran, and these are the bytes it embedded. The + # signature check below is the part with teeth. The shipped-artifact + # check lives in windows-signing-rehearsal.yml, which installs the + # installer and inspects the uninstaller it drops on disk. + if ($env:UNINSTALLER_SIGNING_COMPLETED -eq 'true') { + $signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed\orca-uninstaller.exe' + $receipt = "$signedUninstaller.embedded-sha256" + if (-not (Test-Path -LiteralPath $receipt)) { + $failures.Add('the sign hook did not embed the signed uninstaller into the rebuilt installer') + } else { + $embedded = (Get-Content -LiteralPath $receipt -Raw).Trim() + $actual = (Get-FileHash -LiteralPath $signedUninstaller -Algorithm SHA256).Hash.ToLowerInvariant() + $signature = Get-AuthenticodeSignature -FilePath $signedUninstaller + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + $line = "{0,-14} {1} <{2}>" -f $signature.Status, 'Uninstall Orca.exe (embedded)', $subject + $report.Add($line) + Write-Host $line + if ($embedded -ne $actual) { + $failures.Add("the rebuilt installer embedded different uninstaller bytes than the signed one ($embedded vs $actual)") + } elseif ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { + $failures.Add("not signed by SignPath Foundation: Uninstall Orca.exe ($($signature.Status), $subject)") + } + } + } else { + Write-Host '::warning::The NSIS uninstaller was not signed on this run; it is excluded from the evidence gate (fail-open).' + } foreach ($relative in $targets) { $path = Join-Path $root $relative if (-not (Test-Path $path)) { @@ -1991,7 +2102,9 @@ jobs: Add-GateEvidence "VERDICT: FAILED — $message" Add-GateSummary "FAILED — $message" } else { - $ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation." + # $report, not $targets: the embedded uninstaller is reported but + # is not one of the extracted payload targets. + $ok = "All $($report.Count) checked binaries are signed by SignPath Foundation." Add-GateEvidence "VERDICT: PASSED — $ok" Add-GateSummary "PASSED — $ok" Write-Host $ok diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 6fc6fab7193..90ab8db137c 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -3,9 +3,11 @@ # Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed # inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires # a two-request flow: sign the unpacked PE files first, then build the NSIS -# installer from the signed tree, then sign the installer. This workflow -# rehearses that entire flow from a branch, end to end, without publishing -# anything — so the release pipeline on main is never at risk while we verify. +# installer from the signed tree, then sign the installer. The NSIS uninstaller +# rides that same first request — it is captured through electron-builder's sign +# hook and swapped back in during the rebuild — so it adds no third approval. +# This workflow rehearses that entire flow from a branch, end to end, without +# publishing anything — so the release pipeline on main is never at risk. # # Runs only via manual dispatch. Use the test-signing policy for iteration # (auto-approved test certificate) and release-signing to rehearse the @@ -81,15 +83,27 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=4096 - - name: Package unpacked Windows app + # Why a full --win build and not --dir: the NSIS uninstaller only exists + # inside the installer build, and it is the file the MDE update cluster + # flags. --dir would never produce it, so the rehearsal would not rehearse + # the uninstaller leg at all. This mirrors release-cut's first Windows pass. + - name: Package Windows app and export the NSIS uninstaller shell: pwsh + env: + # runner.temp, never the workspace: the all-negation `files` list in + # config/electron-builder.config.cjs packs whatever is left in the + # checkout root into app.asar. + ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe run: | node config/scripts/ensure-native-runtime.mjs --runtime=electron if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) { - throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe' + throw 'electron-builder --win did not produce dist/win-unpacked/Orca.exe' + } + if (-not (Test-Path -LiteralPath $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH)) { + throw "The sign hook did not export the NSIS uninstaller to $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH" } # Why: only unsigned PE files go to SignPath. Files that already carry a @@ -132,6 +146,17 @@ jobs: Write-Host "Skipped $($skipped.Count) already-signed files:" $skipped | ForEach-Object { Write-Host " $_" } + # Why kept out of inner-signing-list.txt: that list drives the copy-back + # into dist/win-unpacked, and the uninstaller does not live there — it is + # re-injected through the electron-builder sign hook during the rebuild. + # No catch here, unlike the release job: the rehearsal exists to prove + # the flow, so a staging failure must fail it loudly. + $exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe' + $uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe' + New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) | Out-Null + Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force + Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe' + - name: Upload unsigned inner binaries for SignPath id: upload-unsigned-inner uses: actions/upload-artifact@v7 @@ -200,8 +225,27 @@ jobs: throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } + - name: Restore signed uninstaller for the installer rebuild + shell: pwsh + run: | + $signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' | + Select-Object -First 1 + if ($null -eq $signed) { + throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the inner-binaries artifact configuration covers it.' + } + $signature = Get-AuthenticodeSignature -FilePath $signed.FullName + if ($null -eq $signature.SignerCertificate) { + throw 'The returned NSIS uninstaller carries no signature.' + } + $signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed' + New-Item -ItemType Directory -Force -Path $signedDir | Out-Null + Copy-Item -LiteralPath $signed.FullName -Destination (Join-Path $signedDir 'orca-uninstaller.exe') -Force + Write-Host ("{0,-14} uninstaller <{1}>" -f $signature.Status, $signature.SignerCertificate.Subject) + - name: Build NSIS installer from signed unpacked app shell: pwsh + env: + ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe run: | pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -289,20 +333,33 @@ jobs: run: | $report = New-Object System.Collections.Generic.List[string] $failures = New-Object System.Collections.Generic.List[string] + $advisories = New-Object System.Collections.Generic.List[string] $requireValid = $env:SIGNING_POLICY -eq 'release-signing' - function Test-Signature([string]$label, [string]$path) { + # -Advisory records a problem without failing the run. It exists for + # exactly one file (resources\elevate.exe, below) and must not be + # widened casually: the point of this workflow is to fail when signing + # is broken. + function Test-Signature([string]$label, [string]$path, [switch]$Advisory) { $signature = Get-AuthenticodeSignature -FilePath $path $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } $line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject $script:report.Add($line) Write-Host $line + $problem = $null if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') { - $script:failures.Add("unsigned: $label") + $problem = "unsigned: $label" } elseif ($script:requireValid -and $signature.Status -ne 'Valid') { - $script:failures.Add("not Valid under release-signing: $label ($($signature.Status))") + $problem = "not Valid under release-signing: $label ($($signature.Status))" } elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') { - $script:failures.Add("unexpected signer: $label ($subject)") + $problem = "unexpected signer: $label ($subject)" + } + if ($null -eq $problem) { return } + if ($Advisory) { + $script:advisories.Add($problem) + Write-Host "::warning::$problem - known pre-existing issue, not failing the rehearsal" + } else { + $script:failures.Add($problem) } } @@ -324,21 +381,155 @@ jobs: & $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null $root = Resolve-Path 'extracted-app' + # The receipt only proves the import leg ran; it cannot prove what NSIS + # embedded, because the uninstaller lives in a compressed NSIS data + # section rather than the app 7z payload above and the bundled 7za has + # no NSIS handler. So the rehearsal - unlike the release job, which + # must not mutate the runner it publishes from - goes all the way: it + # installs the installer silently and inspects the uninstaller the + # installer actually wrote to disk. That is the file MDE flags. + $signedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed\orca-uninstaller.exe' + $receipt = "$signedUninstaller.embedded-sha256" + if (-not (Test-Path -LiteralPath $receipt)) { + $failures.Add('the sign hook did not embed the signed uninstaller into the rebuilt installer') + } else { + Test-Signature 'relayed: orca-uninstaller.exe' $signedUninstaller + } + + # Why a full 7-Zip attempt first: it is non-invasive. The runner image + # ships the complete 7z.exe, which - unlike the reduced 7za - has an + # NSIS handler. If it cannot read the section either, fall back to a + # real silent install. + $installedUninstaller = $null + $installedVia = $null + $expectedDigest = if (Test-Path -LiteralPath $receipt) { (Get-Content -LiteralPath $receipt -Raw).Trim() } else { $null } + $full7z = 'C:\Program Files\7-Zip\7z.exe' + if (Test-Path -LiteralPath $full7z) { + New-Item -ItemType Directory -Path nsis-extract -Force | Out-Null + & $full7z x -tnsis 'dist/orca-windows-setup.exe' '-onsis-extract' -y 2>&1 | Out-Null + $installedUninstaller = Get-ChildItem -Path nsis-extract -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 + # Why the digest guard before trusting this route: 7-Zip's NSIS + # handler emits partial or garbled output on some NSIS builds, and a + # truncated extract would score NotSigned and fail the rehearsal as + # "the shipped uninstaller is unsigned" when nothing is wrong. Only + # trust it when it reproduces the bytes the relay embedded; otherwise + # fall through to the install route, which is ground truth. A name + # miss (the handler labelling the entry by its source name) falls + # through the same way. + if ($null -ne $installedUninstaller -and $null -ne $expectedDigest -and + (Get-FileHash -LiteralPath $installedUninstaller.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expectedDigest) { + Write-Host "7-Zip's NSIS output did not match the relayed digest; falling back to a silent install." + $installedUninstaller = $null + } + if ($null -ne $installedUninstaller) { + $installedVia = "7-Zip's NSIS handler" + Write-Host "Read the embedded uninstaller with 7-Zip's NSIS handler: $($installedUninstaller.FullName)" + } else { + Write-Host "7-Zip's NSIS handler did not yield a usable uninstaller; falling back to a silent install." + } + } + + if ($null -eq $installedUninstaller) { + # Nothing here is published, so mutating this runner is free. + # Why -PassThru and a bounded wait rather than -Wait: a bare -Wait on + # an installer that ever prompts hangs to the job's 360-minute cap. + $installerProcess = Start-Process -FilePath (Resolve-Path 'dist/orca-windows-setup.exe') -ArgumentList '/S' -PassThru + if (-not $installerProcess.WaitForExit(300000)) { + $installerProcess | Stop-Process -Force -ErrorAction SilentlyContinue + $failures.Add('the silent install did not exit within 5 minutes; it is likely prompting') + } + # Why a poll rather than one Stop-Process: the oneClick installer + # launches the app as it finishes, so Orca.exe can appear *after* the + # installer process exits. A single silenced Stop-Process would miss + # it and leave Orca plus orca-terminal-daemon.exe holding handles + # under %LOCALAPPDATA%\Programs for the rest of the job. + for ($attempt = 0; $attempt -lt 20; $attempt++) { + $running = @(Get-Process -Name 'Orca' -ErrorAction SilentlyContinue) + if ($running.Count -gt 0) { + $running | Stop-Process -Force -ErrorAction SilentlyContinue + break + } + Start-Sleep -Milliseconds 500 + } + Get-Process -Name 'orca-terminal-daemon' -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + $installedUninstaller = Get-ChildItem -Path "$env:LOCALAPPDATA\Programs" -Recurse -File -Filter 'Uninstall*.exe' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like '*Orca*' } | + Select-Object -First 1 + if ($null -ne $installedUninstaller) { $installedVia = 'a silent install' } + } + + if ($null -eq $installedUninstaller) { + $failures.Add('could not obtain the uninstaller the installer ships; neither 7-Zip nor a silent install produced it') + } else { + # Why this digest comparison is the point of the whole rehearsal: + # unlike the release job's, it hashes a file NSIS itself wrote out + # rather than the file the hook copied, so it is the only check that + # proves the shipped installer embedded the SignPath-signed bytes. On + # the 7-Zip route the guard above already forced equality; on the + # install route this is the first time it is tested. + if ($null -ne $expectedDigest) { + $shippedDigest = (Get-FileHash -LiteralPath $installedUninstaller.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if ($shippedDigest -ne $expectedDigest) { + $failures.Add("the uninstaller the installer ships is not the relayed one (via $installedVia): $shippedDigest vs $expectedDigest") + } + } + Test-Signature "shipped: Uninstall Orca.exe (via $installedVia)" $installedUninstaller.FullName + } + foreach ($relative in Get-Content 'inner-signing-list.txt') { $path = Join-Path $root $relative if (-not (Test-Path $path)) { $failures.Add("missing from installer payload: $relative") continue } - Test-Signature "installed: $relative" $path + # Why elevate.exe alone is advisory: app-builder-lib re-copies the + # pristine cached elevate.exe over resources\elevate.exe on EVERY nsis + # pack - AppPackageHelper.packArch calls elevateHelper.copy() before + # buildAppPackage (nsisUtil.js), and CopyElevateHelper.copy does + # `copyFile(elevatePath, outFile, false)` then `signIf(outFile)`, which + # signs nothing because this build configures no certificate. So the + # signed copy restored into win-unpacked is clobbered by the rebuild. + # This predates the uninstaller relay and is not caused by it: with no + # `sign` hook, signIf already returned false at "no signing info + # identified" (windowsSignToolManager.js), so no signtool call was + # displaced. release-cut.yml mitigates it separately by pre-seeding the + # electron-builder cache ("Replace cached elevate.exe with the signed + # copy"); this workflow has no such step, which is why the clobber is + # visible here and not there. Mirroring that step here would not help: + # it only swaps when the copy is already Valid and SignPath-signed, so + # it no-ops under the test certificate. + # + # DO NOT relax that Valid + SignPath-signed guard to make this + # rehearsal go green. This workflow and release-cut.yml share the + # cache key `electron-builder-win-`, and that guard is + # the only thing stopping a test certificate from being seeded into + # the cache a real release restores from. Shipping users a binary + # signed by "Test certificate for 'Orca agent ide [OSS]'" is worse + # than shipping it unsigned. + # + # Fixing elevate.exe belongs in its own PR - it is a UAC elevation + # helper, and it deserves more scrutiny than a footnote in an + # uninstaller change. + if ($relative -eq 'resources\elevate.exe') { + Test-Signature "installed: $relative" $path -Advisory + } else { + Test-Signature "installed: $relative" $path + } } + if ($advisories.Count -gt 0) { + $report.Add('') + $report.Add('ADVISORY (known pre-existing, did not fail this run):') + $advisories | ForEach-Object { $report.Add(" $_") } + } Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n") if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host "::error::$_" } throw "Signing rehearsal failed with $($failures.Count) problems." } - Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed." + Write-Host "All checked binaries are signed, including the uninstaller the installer writes to disk ($($advisories.Count) advisory)." - name: Upload rehearsal evidence and installer if: always() diff --git a/.gitignore b/.gitignore index 8be3fc5b6f4..6722fc5ae54 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,8 @@ docs/** !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-cmd-shim-resolution.md +!docs/reference/windows-daemon-host-relocation.md !docs/reference/windows-edr-posture.md !docs/reference/windows-process-enumeration.md !docs/reference/wsl-runner-verification.md diff --git a/AGENTS.md b/AGENTS.md index 8b0156ba6b1..b0947da0c2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,12 @@ All UI work — layout, color, typography, spacing, component selection, UX beha ## Electron UI Validation +Always run tests and agent-launched apps in the background with `ORCA_BACKGROUND_LAUNCH=1`. +Never steal monitor focus or reveal test windows: no `show()`, `showInactive()`, `bringToFront()`, +`app.focus()`, or OS activation. Use CDP screenshots of hidden renderers. Keep native-focus and +visible-window tests paused on the user's desktop; run them on an isolated display or CI. +Rebuild modified launch-policy code before running an app; stale build wrappers are not safe. + Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation. # Style @@ -47,8 +53,9 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. - **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 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. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one. - **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 daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.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/cloud/docs/relay-reconnect-2026-09-findings.md b/cloud/docs/relay-reconnect-2026-09-findings.md index 426a120c251..580a4da84d8 100644 --- a/cloud/docs/relay-reconnect-2026-09-findings.md +++ b/cloud/docs/relay-reconnect-2026-09-findings.md @@ -989,3 +989,14 @@ Owner: "sure, feel free to drive these." Sequence chosen: Roll 1 first (highest | Monitor dry-run #50 | Dispatched 21:54Z at gen 146 on main `51eed5a1bc`, run 33994385666. **Green** 22:10Z, 16/16 samples. Main had moved to `d7767fb196`; trusted paths identical to `a3c1d32995`. Chain dispatched the c29 `canary-apply` (run 33995164002, protocol 0) 12 s after green. | | c29 canary (run 33995164002, `canary-apply`) | **Success** 22:27Z. Isolate → migration-only at **gen 147**, verifier passed on the old image (1 199 assignments), Terraform applied same-cap template `…20260905221622`, new incarnation on `519f4914` at protocol 0, verifier passed at migration-only, activate → **gen 148**, c29 general, verifier passed (1 199 assignments carried). No `container die` fleet-wide 22:11Z–22:30Z. | | **Roll 1 complete** | Image census 22:30Z from MIG templates: c8–c10, c13–c16, c19–c29 on `519f4914` (18 cells); c7 on `85bf6799` (the earlier rehearsal image, carries the same fix); existing-only c1–c6, c11, c12 and migration-only c17, c18 untouched by design. No serving cell remains on `5aedbca5`. Selector gen 148, membership unchanged from the start of the roll. Zero relay container exits fleet-wide across the roll (01:14Z–22:30Z). Gates used: #19–#50; freezes were all monitor-side (provenance, freshness, flat Asia latency bar, one Cloud Monitoring collector failure), none a fleet health finding. Roll 2 (fresh image with #18722 + #18720) is the next data-plane step and waits on the owner's private-IP window decision. | + +## Roll 2 (image `4916ed67`, 2026-09-06) + +| Step | Result | Evidence | +|---|---|---| +| Docs split | #18958 merged `3bb038a185` (findings, checklist, roadmap, Roll 2 plan). | | +| Code PR | #18959 merged `61b09b7a02` (rebase of #18565 onto main; desktop rotation change dropped since #18719 shipped a proportional version). Two Opus review rounds: round 1 caught the mobile fail-fast rejecting on any socket close (one AP flap would book the 60 s cooldown) → 2 s grace, re-armed once on `handshaking`; round 2 caught a removed jitter assertion that let a one-sided jitter pass → exact pin on the top of the band. Control lease 55 min → 6 h ± 30 min. | | +| Image publish | run 34002233801 → `sha256:4916ed676d8389f694a648e750f1112d9002d68c84a1e0c7af828d5af129de62`; mirrored to staging (run 34002326150). | | +| Staging cell smoke | **Dropped.** Staging C4 is pinned to the Asia launch digest by `relay-staging-c4-refresh-workflow.test.mjs` (with production c27–c29 tfvars and the C4 recovery workflow) and the only C4 image-refresh path pins its accepted predecessor to an older digest. Re-pinning all of it for a smoke widens into the Asia launch machinery; #18969 closed. Roll 2 follows the Roll 1 path: director first, c7 as the rehearsal cell. | | +| Director deploy | run 34002673626 **success** 01:02Z: serving `orca-cloud-relay-00575-leq` on `4916ed67`, `00574-wag` (same image) tagged `selector-rollback`, `00569-ret` (`519f4914`) still deployable. Baseline before: 1 director Postgres retry in the prior hour, 0 `container die`. | | +| c7 `verify` (read-only) | run 34002885408 dispatched 01:03Z, target `4916ed67`, rollback `85bf6799`, protocol 1, gen 148. | | diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ebf4d275678..7e0009b3a24 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -19,6 +19,7 @@ const { } = 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') +const { signWindowsUninstallerViaSignPath } = require('./scripts/windows-uninstaller-signing.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -401,9 +402,17 @@ module.exports = { // name is absent. An unsigned build that still claimed 'SignPath Foundation' // would therefore reject its own channel's next build — and its way back to // stable with it. Dropping it is what makes dev→dev and dev→stable work. - ...(isWinDevChannel - ? { verifyUpdateCodeSignature: false } - : { signtoolOptions: { publisherName: 'SignPath Foundation' } }), + // Why a sign hook on a build that does not sign: it is the only moment + // electron-builder exposes the NSIS uninstaller (built in its own makensis + // pass, embedded, then deleted). The hook signs nothing — it relays the file + // to and from the CI SignPath request, and is inert when the relay env vars + // are unset, so local and dev builds are unaffected. publisherName stays on + // its existing channel split above. + signtoolOptions: { + sign: signWindowsUninstallerViaSignPath, + ...(isWinDevChannel ? {} : { publisherName: 'SignPath Foundation' }) + }, + ...(isWinDevChannel ? { verifyUpdateCodeSignature: false } : {}), extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('win32'), diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index ca80c99fc6d..d89439073ab 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -49,22 +49,48 @@ ; --------------------------------------------------------------------------- ; 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. +; Why: the daemon host is deliberately copied OUT of the install dir into +; %LOCALAPPDATA%\Orca\daemon-host so that app UPDATES cannot kill it — +; electron-builder's kill sweep selects processes whose image path is under +; $INSTDIR, and 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. +; The LOCALAPPDATA folder name must stay in sync with LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. See +; docs/reference/windows-daemon-host-relocation.md. !macro customUnInstall ${ifNot} ${isUpdated} - nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + Push $0 + Push $1 + Push $2 + ; The host exe is a verbatim copy of the app exe, so the app's own image name + ; reaches it; the second name covers hosts left by builds that renamed the copy. + ; Filtered to the current user like upstream's per-user KILL_PROCESS, so an + ; elevated machine-wide uninstall cannot reach another logged-on user's session. + ; NSIS expands USERNAME itself: routing through cmd.exe only to get %USERNAME% + ; would add two interpreter spawns to the uninstall path for nothing. + ReadEnvStr $1 USERNAME + ${if} $1 == "" + ; Measured: taskkill rejects an empty filter value outright ("The search filter + ; cannot be recognized") and kills nothing, so with no USERNAME to scope by, + ; kill unfiltered rather than not at all. USERNAME is set in every session an + ; uninstaller runs in, so this is a backstop, not the expected path. + StrCpy $2 "" + ${else} + StrCpy $2 '/FI "USERNAME eq $1"' + ${endIf} + nsExec::Exec 'taskkill /F /IM "${APP_EXECUTABLE_FILENAME}" $2' + Pop $0 + nsExec::Exec 'taskkill /F /IM "orca-terminal-daemon.exe" $2' + Pop $0 + Pop $2 + Pop $1 + Pop $0 ; Give the OS a moment to release the image lock before removing the tree. Sleep 500 RMDir /r "$LOCALAPPDATA\Orca\daemon-host" diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 10780f5288a..fe5e4be44b1 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -27,15 +27,424 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7 "/guard:cf", "/sdl", diff --git a/src/process.cc b/src/process.cc -index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644 +index 3eea92077c4d1d433119361d5c432881859131e9..738775f6fcdfb676054386fe34c0380327ed1863 100644 --- a/src/process.cc +++ b/src/process.cc -@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector& process_info, - process_info.push_back(std::move(pinfo)); - process_count++; - } +@@ -1,108 +1,112 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-#include "process.h" +-#include "process_commandline.h" +- +-#include +-#include +-#include +- +-uint32_t GetRawProcessList(std::vector& process_info, +- DWORD process_data_flags) { +- // Fetch the PID and PPIDs +- PROCESSENTRY32 process_entry = { 0 }; +- DWORD parent_pid = 0; +- uint32_t process_count = 0; +- HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); +- process_entry.dwSize = sizeof(PROCESSENTRY32); +- if (Process32First(snapshot_handle, &process_entry)) { +- do { +- if (process_entry.th32ProcessID != 0) { +- ProcessInfo pinfo; +- pinfo.pid = process_entry.th32ProcessID; +- pinfo.ppid = process_entry.th32ParentProcessID; +- +- if (MEMORY & process_data_flags) { +- GetProcessMemoryUsage(pinfo); +- } +- +- if (COMMANDLINE & process_data_flags) { +- GetProcessCommandLine(pinfo); +- } +- +- strcpy(pinfo.name, process_entry.szExeFile); +- process_info.push_back(std::move(pinfo)); +- process_count++; +- } - } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry)); +- } +- +- CloseHandle(snapshot_handle); +- return process_count; +-} +- +-void GetProcessMemoryUsage(ProcessInfo& process_info) { +- DWORD pid = process_info.pid; +- HANDLE hProcess; +- PROCESS_MEMORY_COUNTERS pmc; +- +- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid); +- +- if (hProcess == NULL) { +- return; +- } +- +- if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) { +- process_info.memory = (DWORD)pmc.WorkingSetSize; +- } +- +- CloseHandle(hProcess); +-} +- +-// Per documentation, it is not recommended to add or subtract values from the FILETIME +-// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows. +-// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead. +-// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx +-ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) { +- ULARGE_INTEGER kt, ut; +- kt.LowPart = (*kernelTime).dwLowDateTime; +- kt.HighPart = (*kernelTime).dwHighDateTime; +- +- ut.LowPart = (*userTime).dwLowDateTime; +- ut.HighPart = (*userTime).dwHighDateTime; +- +- return kt.QuadPart + ut.QuadPart; +-} +- +-void GetCpuUsage(Cpu& cpu_info, bool first_pass) { +- DWORD pid = cpu_info.pid; +- HANDLE hProcess; +- +- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid); +- +- if (hProcess == NULL) { +- return; +- } +- +- FILETIME creationTime, exitTime, kernelTime, userTime; +- FILETIME sysIdleTime, sysKernelTime, sysUserTime; +- if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime) +- && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) { +- if (first_pass) { +- cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime); +- cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime); +- } else { +- ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime); +- ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime); +- +- cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime); +- } +- } else { +- cpu_info.cpu = std::numeric_limits::quiet_NaN(); +- } +- +- CloseHandle(hProcess); ++/*--------------------------------------------------------------------------------------------- ++ * Copyright (c) Microsoft Corporation. All rights reserved. ++ * Licensed under the MIT License. See License.txt in the project root for license information. ++ *--------------------------------------------------------------------------------------------*/ ++ ++#include "process.h" ++#include "process_commandline.h" ++ ++#include ++#include ++#include ++ ++uint32_t GetRawProcessList(std::vector& process_info, ++ DWORD process_data_flags) { ++ // Fetch the PID and PPIDs ++ PROCESSENTRY32 process_entry = { 0 }; ++ DWORD parent_pid = 0; ++ uint32_t process_count = 0; ++ HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); ++ process_entry.dwSize = sizeof(PROCESSENTRY32); ++ if (Process32First(snapshot_handle, &process_entry)) { ++ do { ++ if (process_entry.th32ProcessID != 0) { ++ // Value-initialize: `memory` is otherwise stack garbage when the flag is unset. ++ ProcessInfo pinfo{}; ++ pinfo.pid = process_entry.th32ProcessID; ++ pinfo.ppid = process_entry.th32ParentProcessID; ++ ++ if (MEMORY & process_data_flags) { ++ GetProcessMemoryUsage(pinfo); ++ } ++ ++ if (COMMANDLINE & process_data_flags) { ++ GetProcessCommandLine(pinfo); ++ } ++ ++ strcpy(pinfo.name, process_entry.szExeFile); ++ process_info.push_back(std::move(pinfo)); ++ process_count++; ++ } + } while (Process32Next(snapshot_handle, &process_entry)); - } - - CloseHandle(snapshot_handle); ++ } ++ ++ CloseHandle(snapshot_handle); ++ return process_count; ++} ++ ++void GetProcessMemoryUsage(ProcessInfo& process_info) { ++ DWORD pid = process_info.pid; ++ HANDLE hProcess; ++ PROCESS_MEMORY_COUNTERS pmc; ++ ++ // PROCESS_VM_READ is never used here -- GetProcessMemoryInfo reads counters the ++ // kernel keeps, not the address space -- and acquiring it is what EDR scores. ++ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); ++ ++ if (hProcess == NULL) { ++ return; ++ } ++ ++ if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) { ++ process_info.memory = (DWORD)pmc.WorkingSetSize; ++ } ++ ++ CloseHandle(hProcess); ++} ++ ++// Per documentation, it is not recommended to add or subtract values from the FILETIME ++// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows. ++// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead. ++// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx ++ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) { ++ ULARGE_INTEGER kt, ut; ++ kt.LowPart = (*kernelTime).dwLowDateTime; ++ kt.HighPart = (*kernelTime).dwHighDateTime; ++ ++ ut.LowPart = (*userTime).dwLowDateTime; ++ ut.HighPart = (*userTime).dwHighDateTime; ++ ++ return kt.QuadPart + ut.QuadPart; ++} ++ ++void GetCpuUsage(Cpu& cpu_info, bool first_pass) { ++ DWORD pid = cpu_info.pid; ++ HANDLE hProcess; ++ ++ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION. ++ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); ++ ++ if (hProcess == NULL) { ++ return; ++ } ++ ++ FILETIME creationTime, exitTime, kernelTime, userTime; ++ FILETIME sysIdleTime, sysKernelTime, sysUserTime; ++ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime) ++ && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) { ++ if (first_pass) { ++ cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime); ++ cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime); ++ } else { ++ ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime); ++ ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime); ++ ++ cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime); ++ } ++ } else { ++ cpu_info.cpu = std::numeric_limits::quiet_NaN(); ++ } ++ ++ CloseHandle(hProcess); + } +\ No newline at end of file +diff --git a/src/process_commandline.cc b/src/process_commandline.cc +index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3210c3cfd 100644 +--- a/src/process_commandline.cc ++++ b/src/process_commandline.cc +@@ -1,67 +1,125 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-#include "process.h" +-#include "process_commandline.h" +-#include +-#include +-#include +- +-bool GetProcessCommandLine(ProcessInfo& process_info) { +- HINSTANCE ntdll = GetModuleHandleW(L"ntdll.dll"); +- if (!ntdll) { +- return false; +- } +- +- decltype(NtQueryInformationProcess)* nt_query_information_process = +- reinterpret_cast( +- GetProcAddress(ntdll, "NtQueryInformationProcess")); +- +- if (!nt_query_information_process) { +- return false; +- } +- +- PROCESS_BASIC_INFORMATION pbi{}; +- PEB peb = {NULL}; +- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL}; +- +- // Get process handle +- DWORD pid = process_info.pid; +- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid); +- if (hProcess == INVALID_HANDLE_VALUE) { +- return false; +- } +- +- // Get Process Environment Block (PEB) +- NTSTATUS status = nt_query_information_process(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr); +- if (NT_SUCCESS(status) && pbi.PebBaseAddress) { +- // Read PEB +- if (ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr)) { +- // Read the processs parameters +- if (ReadProcessMemory(hProcess, peb.ProcessParameters, &process_parameters, sizeof(RTL_USER_PROCESS_PARAMETERS), nullptr)) { +- if (process_parameters.CommandLine.Length > 0) { +- std::wstring buffer; +- buffer.resize(process_parameters.CommandLine.Length / sizeof(wchar_t)); +- if (ReadProcessMemory(hProcess, process_parameters.CommandLine.Buffer, &buffer[0], process_parameters.CommandLine.Length, nullptr)) { +- int wide_length = static_cast(buffer.length()); +- int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length, +- NULL, 0, NULL, NULL); +- if (charcount) { +- process_info.commandLine.resize(static_cast(charcount)); +- WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length, +- &process_info.commandLine[0], charcount, +- NULL, NULL); +- } +- CloseHandle(hProcess); +- return true; +- } +- } +- } +- } +- } +- +- CloseHandle(hProcess); +- return false; +-} ++/*--------------------------------------------------------------------------------------------- ++ * Copyright (c) Microsoft Corporation. All rights reserved. ++ * Licensed under the MIT License. See License.txt in the project root for license information. ++ *--------------------------------------------------------------------------------------------*/ ++ ++#include "process.h" ++#include "process_commandline.h" ++#include ++#include ++#include ++ ++namespace { ++ ++// Windows 8.1 and later hand back a process's command line as a UNICODE_STRING ++// the kernel builds, needing only PROCESS_QUERY_LIMITED_INFORMATION. ++// ++// There is deliberately no PEB fallback. Reading the command line out of the ++// target's address space -- opening it for VM reads and then chaining ++// memory reads across every pid on a timer -- is the credential-dumping ++// primitive this reader exists to not perform, so it is absent from the binary ++// rather than one anomalous NTSTATUS away. Electron's floor is Windows 10, so ++// every OS Orca supports has this class; if a hooked ntdll refuses it anyway, ++// the command line comes back empty, which callers already handle, instead of ++// silently reinstating the primitive on exactly the instrumented machines this ++// reader was written for. ++const ULONG kProcessCommandLineInformation = 60; ++ ++const NTSTATUS kStatusInfoLengthMismatch = static_cast(0xC0000004L); ++const NTSTATUS kStatusBufferTooSmall = static_cast(0xC0000023L); ++ ++// A command line is a UNICODE_STRING, whose Length is a USHORT, so the kernel ++// can never need more than the header plus 64 KiB. Refusing anything larger ++// keeps a bogus size from throwing bad_alloc out of a scan that has already ++// walked most of the table. ++const ULONG kMaxCommandLineBytes = sizeof(UNICODE_STRING) + 0xFFFF + sizeof(wchar_t); ++ ++// winternl.h's PROCESSINFOCLASS does not name class 60 and its enumerator range ++// stops far short of it, so the class travels as a ULONG rather than a cast enum. ++typedef NTSTATUS(NTAPI* NtQueryInformationProcessFn)(HANDLE, ULONG, PVOID, ULONG, PULONG); ++ ++// ntdll ships no import library for this entry point; it has to be resolved. ++NtQueryInformationProcessFn ResolveNtQueryInformationProcess() { ++ HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); ++ if (!ntdll) { ++ return nullptr; ++ } ++ return reinterpret_cast( ++ GetProcAddress(ntdll, "NtQueryInformationProcess")); ++} ++ ++NtQueryInformationProcessFn NtQueryInformationProcessEntry() { ++ static NtQueryInformationProcessFn entry = ResolveNtQueryInformationProcess(); ++ return entry; ++} ++ ++bool StoreCommandLineUtf8(ProcessInfo& process_info, const wchar_t* data, size_t wide_length) { ++ if (wide_length == 0) { ++ return false; ++ } ++ int length = static_cast(wide_length); ++ int charcount = WideCharToMultiByte(CP_UTF8, 0, data, length, NULL, 0, NULL, NULL); ++ if (!charcount) { ++ return false; ++ } ++ process_info.commandLine.resize(static_cast(charcount)); ++ WideCharToMultiByte(CP_UTF8, 0, data, length, &process_info.commandLine[0], charcount, NULL, ++ NULL); ++ return true; ++} ++ ++} // namespace ++ ++bool GetProcessCommandLine(ProcessInfo& process_info) { ++ NtQueryInformationProcessFn query = NtQueryInformationProcessEntry(); ++ if (!query) { ++ return false; ++ } ++ ++ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_info.pid); ++ if (process == NULL) { ++ return false; ++ } ++ ++ ULONG size = 0; ++ NTSTATUS status = query(process, kProcessCommandLineInformation, nullptr, 0, &size); ++ if (NT_SUCCESS(status)) { ++ // Nothing was written, so there is no command line to read. ++ CloseHandle(process); ++ return false; ++ } ++ if (status != kStatusInfoLengthMismatch && status != kStatusBufferTooSmall) { ++ CloseHandle(process); ++ return false; ++ } ++ if (size < sizeof(UNICODE_STRING) || size > kMaxCommandLineBytes) { ++ CloseHandle(process); ++ return false; ++ } ++ ++ std::vector buffer(size); ++ status = query(process, kProcessCommandLineInformation, &buffer[0], size, &size); ++ CloseHandle(process); ++ if (!NT_SUCCESS(status)) { ++ return false; ++ } ++ ++ // Header and characters arrive in one allocation, but treat the header as ++ // untrusted: a hooked ntdll is the case this reader is written for, and an ++ // unchecked Buffer/Length here would be an over-read encoded straight into JS. ++ // Bound against buffer.size(), never `size` -- the second query overwrote it. ++ const UNICODE_STRING* command_line = reinterpret_cast(&buffer[0]); ++ const unsigned char* begin = &buffer[0]; ++ const unsigned char* end = begin + buffer.size(); ++ const unsigned char* chars = reinterpret_cast(command_line->Buffer); ++ if (chars == nullptr || chars < begin + sizeof(UNICODE_STRING) || chars > end || ++ command_line->Length > static_cast(end - chars)) { ++ return false; ++ } ++ ++ // True only when a command line was actually stored, so "empty" and "not ++ // recovered" stay the same answer they were before this reader replaced the ++ // PEB read. `src/process.cc` discards the result either way. ++ return StoreCommandLineUtf8(process_info, command_line->Buffer, ++ command_line->Length / sizeof(wchar_t)); ++} diff --git a/config/scripts/benchmark-browser-tunnel-framing.mjs b/config/scripts/benchmark-browser-tunnel-framing.mjs new file mode 100644 index 00000000000..e91fd0887f6 --- /dev/null +++ b/config/scripts/benchmark-browser-tunnel-framing.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' + +// Run from the worktree root: node config/scripts/benchmark-browser-tunnel-framing.mjs [base-ref] +const path = 'src/shared/browser-network-tunnel-stream-framing.ts' +const baselineRef = process.argv[2] ?? 'HEAD' +const beforeSource = execFileSync('git', ['show', `${baselineRef}:${path}`], { + encoding: 'utf8' +}) +const afterSource = readFileSync(path, 'utf8') +const load = (source) => + import( + `data:text/javascript;base64,${Buffer.from( + stripTypeScriptTypes(source, { mode: 'transform' }) + ).toString('base64')}` + ) +const before = await load(beforeSource) +const after = await load(afterSource) + +function measure(module, chunks, payload, repetitions) { + let frameCount = 0 + let lastFrame + const onFrame = (frame) => { + frameCount++ + lastFrame = frame + } + const onError = (error) => { + throw error + } + const run = () => { + const decoder = new module.BrowserNetworkTunnelStreamFrameDecoder(onFrame, onError) + for (const chunk of chunks) { + decoder.feed(chunk) + } + } + run() + assert.deepEqual(lastFrame, payload) + const samples = [] + for (let sample = 0; sample < 5; sample++) { + const start = performance.now() + for (let iteration = 0; iteration < repetitions; iteration++) { + run() + } + samples.push((performance.now() - start) / repetitions) + } + assert.equal(frameCount, 1 + 5 * repetitions) + return samples.sort((a, b) => a - b)[2] +} + +function countCopies(module, chunks) { + const originalSet = Uint8Array.prototype.set + const originalSlice = Uint8Array.prototype.slice + let copied = 0 + Uint8Array.prototype.set = function (source, offset) { + copied += source.length + return originalSet.call(this, source, offset) + } + Uint8Array.prototype.slice = function (...args) { + const result = originalSlice.apply(this, args) + copied += result.length + return result + } + try { + const decoder = new module.BrowserNetworkTunnelStreamFrameDecoder( + () => {}, + (error) => { + throw error + } + ) + for (const chunk of chunks) { + decoder.feed(chunk) + } + } finally { + Uint8Array.prototype.set = originalSet + Uint8Array.prototype.slice = originalSlice + } + return copied +} + +const rows = [] +for (const [payloadBytes, chunkBytes, repetitions] of [ + [1, 5, 10000], + [64 * 1024, 65540, 1000], + [64 * 1024, 4096, 100], + [64 * 1024, 256, 25], + [64 * 1024, 16, 5], + [64 * 1024, 1, 1] +]) { + const payload = Uint8Array.from({ length: payloadBytes }, (_, index) => index % 251) + const encoded = before.encodeBrowserNetworkTunnelStreamFrame(payload) + const chunks = [] + for (let offset = 0; offset < encoded.length; offset += chunkBytes) { + chunks.push(encoded.subarray(offset, offset + chunkBytes)) + } + const beforeMs = measure(before, chunks, payload, repetitions) + const afterMs = measure(after, chunks, payload, repetitions) + rows.push({ + payloadBytes, + chunkBytes, + beforeMs: +beforeMs.toFixed(6), + afterMs: +afterMs.toFixed(6), + speedup: +(beforeMs / afterMs).toFixed(2), + beforeCopiedBytes: countCopies(before, chunks), + afterCopiedBytes: countCopies(after, chunks) + }) +} +console.log(JSON.stringify({ node: process.version, baselineRef, rows }, null, 2)) diff --git a/config/scripts/benchmark-cli-error-imports.mjs b/config/scripts/benchmark-cli-error-imports.mjs new file mode 100644 index 00000000000..a4648f84aec --- /dev/null +++ b/config/scripts/benchmark-cli-error-imports.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { existsSync, realpathSync } from 'node:fs' +import { delimiter, join, resolve } from 'node:path' + +// Emit each revision with tsc -p config/tsconfig.cli.json --outDir --composite false --incremental false. +// Run: node config/scripts/benchmark-cli-error-imports.mjs +const [beforeDir, afterDir] = process.argv.slice(2) +assert.ok(beforeDir && afterDir, 'Pass distinct before and after TypeScript output directories.') +assert.notEqual( + realpathSync(beforeDir), + realpathSync(afterDir), + 'Do not compare a build to itself.' +) +const entries = { + before: join(resolve(beforeDir), 'cli', 'index.js'), + after: join(resolve(afterDir), 'cli', 'index.js') +} +for (const entry of Object.values(entries)) { + assert.ok(existsSync(entry), `Missing emitted CLI: ${entry}`) +} + +const { runProcessSync } = createRequire(import.meta.url)( + join(resolve(afterDir), 'shared', 'child-process', 'run-process.js') +) + +const child = String.raw` + const { performance } = require('node:perf_hooks') + const { writeSync } = require('node:fs') + const { createHash } = require('node:crypto') + const { basename } = require('node:path') + let stdout = '', stderr = '' + process.stdout.write = (text) => { stdout += text; return true } + process.stderr.write = (text) => { stderr += text; return true } + const started = performance.now() + const cli = require(process.argv[1]) + const importMs = performance.now() - started + cli.main(JSON.parse(process.argv[2])).then(() => { + const totalMs = performance.now() - started + const modules = Object.keys(require.cache) + writeSync(1, JSON.stringify({ + importMs, totalMs, modules: modules.length, + featureFormatters: modules.filter((file) => ['browser', 'terminal', 'project', 'automation', 'workspace', 'computer'].some((name) => basename(file) === name + '-format.js')), + stdout: createHash('sha256').update(stdout).digest('hex'), + stderr: createHash('sha256').update(stderr).digest('hex'), + exitCode: process.exitCode || 0 + })) + process.exitCode = 0 + }).catch((error) => { writeSync(2, String(error)); process.exitCode = 1 }) +` +const cases = [ + ['--help'], + ['help', 'terminal', 'read'], + ['does-not-exist'], + ['computer', 'click', '--does-not-exist'], + ['does-not-exist', '--json'] +] +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] +const summarize = (samples) => ({ + importMs: median(samples.map((sample) => sample.importMs)), + totalMs: median(samples.map((sample) => sample.totalMs)), + modules: samples[0].modules +}) +const rows = [] +for (const args of cases) { + const samples = { before: [], after: [] } + let expected + for (let run = 0; run < 22; run++) { + for (const variant of run % 2 ? ['after', 'before'] : ['before', 'after']) { + const result = runProcessSync({ + program: process.execPath, + args: ['-e', child, entries[variant], JSON.stringify(args)], + timeoutMs: 30_000, + env: { + ...process.env, + NODE_PATH: [resolve('node_modules'), process.env.NODE_PATH] + .filter(Boolean) + .join(delimiter) + } + }) + assert.equal(result.timedOut, false, 'CLI child timed out.') + assert.equal(result.code, 0, result.stderr) + const sample = JSON.parse(result.stdout) + const output = { stdout: sample.stdout, stderr: sample.stderr, exitCode: sample.exitCode } + expected ??= output + assert.deepEqual(output, expected, `${variant} output changed for ${args.join(' ')}`) + if (variant === 'after') { + assert.deepEqual( + sample.featureFormatters, + [], + 'Help and syntax errors must skip feature formatters.' + ) + } + if (run >= 2) { + samples[variant].push(sample) + } + } + } + assert.ok(samples.after[0].modules < samples.before[0].modules, 'Expected fewer loaded modules.') + rows.push({ + args, + before: summarize(samples.before), + after: summarize(samples.after), + output: expected, + samples + }) +} +console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + measurement: + 'Fresh-process import + main; excludes process creation; warmed filesystem; 2 warmups and 20 samples per variant, alternating order.', + entries, + rows + }, + null, + 2 + ) +) diff --git a/config/scripts/benchmark-cli-response-framing.mjs b/config/scripts/benchmark-cli-response-framing.mjs new file mode 100644 index 00000000000..40aab8d08f7 --- /dev/null +++ b/config/scripts/benchmark-cli-response-framing.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { readFileSync } from 'node:fs' +import Module from 'node:module' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Run from the worktree root: node config/scripts/benchmark-cli-response-framing.mjs +const sourcePath = 'src/cli/runtime/transport.ts' +const baselineRef = process.argv[2] +assert.ok(baselineRef, 'Pass the pre-change transport revision as base-ref.') +const beforeSource = execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], { + encoding: 'utf8' +}) +let chunks = [] + +async function loadTransport(source) { + const built = await build({ + stdin: { contents: source, loader: 'ts', resolveDir: dirname(resolve(sourcePath)) }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent' + }) + const module = new Module(resolve(sourcePath)) + const originalRequire = module.require.bind(module) + module.require = (name) => { + if (name === 'node:crypto') { + return { randomUUID: () => 'benchmark-request' } + } + if (name !== 'node:net') { + return originalRequire(name) + } + return { + createConnection() { + const socket = new EventEmitter() + socket.setEncoding = () => {} + socket.end = () => {} + socket.destroy = () => {} + socket.write = () => { + for (const chunk of chunks) { + socket.emit('data', chunk) + } + } + queueMicrotask(() => socket.emit('connect')) + return socket + } + } + } + module._compile(built.outputFiles[0].text, resolve(sourcePath)) + return module.exports.sendRequest +} + +const before = await loadTransport(beforeSource) +const after = await loadTransport(readFileSync(sourcePath, 'utf8')) +const metadata = { + runtimeId: 'benchmark-runtime', + authToken: 'benchmark-token', + transports: [{ kind: 'unix', endpoint: 'injected-socket' }] +} +const run = (sendRequest) => sendRequest(metadata, 'terminal.read', {}, 30000) + +async function measure(sendRequest, payloadBytes, repetitions) { + const warmup = await run(sendRequest) + assert.equal(warmup.result.data.length, payloadBytes) + const samples = [] + for (let sample = 0; sample < 5; sample++) { + const start = performance.now() + for (let iteration = 0; iteration < repetitions; iteration++) { + await run(sendRequest) + } + samples.push((performance.now() - start) / repetitions) + } + return samples.sort((a, b) => a - b)[2] +} + +async function searchedCharacters(sendRequest) { + const original = String.prototype.indexOf + let searched = 0 + String.prototype.indexOf = function (needle, position) { + if (needle === '\n') { + searched += this.length - (position ?? 0) + } + return original.call(this, needle, position) + } + try { + await run(sendRequest) + } finally { + String.prototype.indexOf = original + } + return searched +} + +const rows = [] +for (const [payloadBytes, chunkChars, repetitions] of [ + [32, 65536, 1000], + [1024 * 1024, 2 * 1024 * 1024, 20], + [1024 * 1024, 65536, 10], + [1024 * 1024, 4096, 5], + [4 * 1024 * 1024, 4096, 2], + [4 * 1024 * 1024, 256, 1] +]) { + const line = `${JSON.stringify({ + id: 'benchmark-request', + ok: true, + result: { data: 'x'.repeat(payloadBytes) }, + _meta: { runtimeId: 'benchmark-runtime' } + })}\n` + chunks = [] + for (let offset = 0; offset < line.length; offset += chunkChars) { + chunks.push(line.slice(offset, offset + chunkChars)) + } + const beforeMs = await measure(before, payloadBytes, repetitions) + const afterMs = await measure(after, payloadBytes, repetitions) + rows.push({ + payloadBytes, + chunkChars, + beforeMs: +beforeMs.toFixed(6), + afterMs: +afterMs.toFixed(6), + speedup: +(beforeMs / afterMs).toFixed(2), + beforeSearchedCharacters: await searchedCharacters(before), + afterSearchedCharacters: await searchedCharacters(after) + }) +} +console.log(JSON.stringify({ node: process.version, baselineRef, rows }, null, 2)) diff --git a/config/scripts/benchmark-explorer-dotfile-filter.mjs b/config/scripts/benchmark-explorer-dotfile-filter.mjs new file mode 100644 index 00000000000..e66a0ceb5af --- /dev/null +++ b/config/scripts/benchmark-explorer-dotfile-filter.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import Module from 'node:module' +import { resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pass the pre-change file-explorer-entries.ts snapshot as the only argument. +const baselinePath = process.argv[2] +assert.ok(baselinePath, 'Pass a pre-change file-explorer-entries.ts snapshot.') +const entry = 'src/renderer/src/components/right-sidebar/file-explorer-entries.ts' +const baseline = readFileSync(baselinePath, 'utf8') +assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.') + +async function load(useBaseline) { + const result = await build({ + stdin: { + contents: `export { isDotfileRelativePath } from './${entry}'; +export { createNameFilteredFileExplorerProjection } from './src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts';`, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent', + alias: { '@': resolve('src/renderer/src') }, + plugins: useBaseline + ? [ + { + name: 'baseline-dotfile-predicate', + setup(builder) { + builder.onLoad({ filter: /file-explorer-entries\.ts$/ }, () => ({ + contents: baseline, + loader: 'ts' + })) + } + } + ] + : [] + }) + const module = new Module(resolve('dotfile-benchmark.cjs')) + module.paths = Module._nodeModulePaths(process.cwd()) + module._compile(result.outputFiles[0].text, module.id) + return module.exports +} + +const versions = [await load(true), await load(false)] +let parityCases = 0 +function check(path, depth) { + assert.equal( + versions[0].isDotfileRelativePath(path), + versions[1].isDotfileRelativePath(path), + path + ) + parityCases++ + if (depth > 0) { + for (const character of ['.', '/', '\\', 'a', '\n']) { + check(path + character, depth - 1) + } + } +} +check('', 8) + +function measure(functions, iterations = 1) { + let sink = 0 + const run = (fn) => { + for (let i = 0; i < iterations; i++) { + sink += Number(fn()) + } + } + for (const fn of functions) { + for (let warmup = 0; warmup < 3; warmup++) { + run(fn) + } + } + const samples = [[], []] + for (let round = 0; round < 11; round++) { + for (const variant of round % 2 ? [1, 0] : [0, 1]) { + const start = performance.now() + run(functions[variant]) + samples[variant].push(performance.now() - start) + } + } + return { + beforeMs: samples[0].sort((a, b) => a - b)[5], + afterMs: samples[1].sort((a, b) => a - b)[5], + iterations, + sink + } +} + +const predicates = [] +for (const path of [ + 'a', + '.env', + 'packages/pkg/src/file.tsx', + `a${'.'.repeat(254)}`, + `${'/'.repeat(4096)}.`, + `${'../'.repeat(1000)}file.ts`, + '😀/.你好', + '\n/.\n' +]) { + check(path, 0) + predicates.push({ + pathLength: path.length, + prefix: path.slice(0, 40), + ...measure( + versions.map((version) => () => version.isDotfileRelativePath(path)), + 10_000 + ) + }) +} + +const projections = [] +for (const count of [1000, 10_000, 100_000]) { + for (const query of ['nonmatching-needle', 'file-42']) { + const args = { + ignoredSet: new Set(['unrelated']), + nameFilter: { + query, + relativePaths: Array.from( + { length: count }, + (_, i) => `packages/package-${i % 50}/src/components/section-${i % 10}/file-${i}.tsx` + ) + }, + showDotfiles: false, + showGitIgnoredFiles: false, + worktreePath: '/workspace' + } + const functions = versions.map( + (version) => () => version.createNameFilteredFileExplorerProjection(args) + ) + const rows = functions.map((fn) => { + const projection = fn() + return Array.from({ length: projection.getVisibleCount() }, (_, i) => + projection.getRowAtIndex(i) + ) + }) + assert.deepEqual(rows[0], rows[1]) + projections.push({ + count, + query, + visibleRows: rows[0].length, + ...measure(functions.map((fn) => () => fn().getVisibleCount())) + }) + } +} +console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + baselinePath: resolve(baselinePath), + parityCases, + samples: 11, + warmups: 3, + predicates, + projections + }, + null, + 2 + ) +) diff --git a/config/scripts/benchmark-sentinel-retention.mjs b/config/scripts/benchmark-sentinel-retention.mjs new file mode 100644 index 00000000000..93564eeac01 --- /dev/null +++ b/config/scripts/benchmark-sentinel-retention.mjs @@ -0,0 +1,72 @@ +import { strict as assert } from 'node:assert' +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' + +if (!global.gc) { + throw new Error('Run with node --expose-gc') +} +const root = resolve(import.meta.dirname, '../..') +const directory = await mkdtemp(join(tmpdir(), 'orca-sentinel-retention-')) +const output = join(directory, 'sentinel.cjs') +try { + await build({ + stdin: { + contents: `export {waitForSentinel} from './src/main/ssh/ssh-relay-deploy-helpers'; +export {RELAY_SENTINEL} from './src/main/ssh/relay-protocol';`, + resolveDir: root, + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external', + banner: { + js: `var require = require('node:module').createRequire(${JSON.stringify(join(root, 'package.json'))});` + }, + outfile: output + }) + const { waitForSentinel, RELAY_SENTINEL } = createRequire(import.meta.url)(output) + const held = [] + const banners = [] + for (let i = 0; i < 100; i++) { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + stdin: { write: () => true }, + close: () => {} + }) + const pending = waitForSentinel(channel) + banners.push(feedBanner(channel)) + channel.emit('data', Buffer.from(RELAY_SENTINEL)) + const transport = await pending + const received = [] + transport.onData((bytes) => received.push(bytes.toString())) + channel.emit('data', Buffer.from('frame')) + assert.deepEqual(received, ['frame']) + held.push({ channel, transport }) + } + await new Promise((resolve) => setImmediate(resolve)) + for (let i = 0; i < 5; i++) { + global.gc() + } + const retained = banners.filter((reference) => reference.deref() !== undefined).length + console.log( + JSON.stringify({ + connections: held.length, + bannerBytes: 65536, + retainedBannerBuffers: retained, + retainedBannerBytes: retained * 65536 + }) + ) +} finally { + await rm(directory, { recursive: true, force: true }) +} + +function feedBanner(channel) { + const banner = Buffer.alloc(65536, 120) + channel.emit('data', banner) + return new WeakRef(banner.buffer) +} diff --git a/config/scripts/benchmark-skill-depth.mjs b/config/scripts/benchmark-skill-depth.mjs new file mode 100644 index 00000000000..1ebb606f93c --- /dev/null +++ b/config/scripts/benchmark-skill-depth.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import Module from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pass a pre-change skill-root-file-walk.ts snapshot as the only argument. +const baselinePath = process.argv[2] +const brokenLinks = process.argv.includes('--broken') +assert.ok(baselinePath, 'Pass a pre-change skill-root-file-walk.ts snapshot.') +const entry = 'src/main/skills/skill-root-file-walk.ts' +const baseline = readFileSync(baselinePath, 'utf8') +assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.') +let statCalls = 0 + +async function load(useBaseline) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent', + plugins: useBaseline + ? [ + { + name: 'baseline-skill-depth', + setup(builder) { + builder.onLoad({ filter: /skill-root-file-walk\.ts$/ }, () => ({ + contents: baseline, + loader: 'ts' + })) + } + } + ] + : [] + }) + const module = new Module(resolve('skill-depth-benchmark.cjs')) + module.paths = Module._nodeModulePaths(process.cwd()) + const originalRequire = module.require.bind(module) + module.require = (name) => + name === 'node:fs/promises' + ? { + ...fs, + stat: (...args) => { + statCalls++ + return fs.stat(...args) + } + } + : originalRequire(name) + module._compile(result.outputFiles[0].text, module.id) + return module.exports.findSkillFiles +} + +const before = await load(true) +const after = await load(false) +const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] +const temporaryRoot = await fs.mkdtemp(join(tmpdir(), 'orca-skill-depth-benchmark-')) +try { + for (const links of [0, 8, 100, 1000]) { + const root = join(temporaryRoot, String(links)) + const edge = join(root, 'a', 'b', 'c', 'd') + const target = join(temporaryRoot, 'target') + await fs.mkdir(edge, { recursive: true }) + await fs.mkdir(target, { recursive: true }) + await fs.writeFile(join(target, 'SKILL.md'), 'skill') + await fs.writeFile(join(edge, 'SKILL.md'), 'edge') + for (let index = 0; index < links; index++) { + await fs.symlink( + brokenLinks ? join(target, 'missing') : target, + join(edge, `link${index}`), + process.platform === 'win32' ? 'junction' : 'dir' + ) + } + for (const depth of [4, 5]) { + const timings = { before: [], after: [] } + const counts = {} + let rows + for (let sample = 0; sample < 13; sample++) { + const versions = + sample % 2 + ? [ + ['after', after], + ['before', before] + ] + : [ + ['before', before], + ['after', after] + ] + for (const [name, walk] of versions) { + statCalls = 0 + const start = performance.now() + const result = await walk(root, depth) + const elapsed = performance.now() - start + if (rows) { + assert.deepEqual(result, rows) + } + rows = result + counts[name] = statCalls + if (sample >= 2) { + timings[name].push(elapsed) + } + } + } + console.log( + JSON.stringify({ + links, + brokenLinks, + depth, + statCalls: counts, + rows: rows.length, + medianMs: { before: median(timings.before), after: median(timings.after) } + }) + ) + } + } +} finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) +} diff --git a/config/scripts/benchmark-tab-group-repair.mjs b/config/scripts/benchmark-tab-group-repair.mjs new file mode 100644 index 00000000000..17a1161fc4c --- /dev/null +++ b/config/scripts/benchmark-tab-group-repair.mjs @@ -0,0 +1,80 @@ +import { strict as assert } from 'node:assert' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const root = resolve(import.meta.dirname, '../..') +const source = join(root, 'src/renderer/src/store/slices/tab-group-reference-repair.ts') +const directory = await mkdtemp(join(tmpdir(), 'orca-tab-repair-')) +const current = await readFile(source, 'utf8') +const indexed = `const orderedTabIds = new Set(group.tabOrder) + const missingTabIds = ownedTabIds.filter((tabId) => !orderedTabIds.has(tabId))` +assert(current.includes(indexed), 'Expected indexed implementation') +try { + const implementations = [] + for (const baseline of [true, false]) { + const outfile = join(directory, baseline ? 'before.cjs' : 'after.cjs') + await build({ + stdin: { + contents: baseline + ? current.replace( + indexed, + 'const missingTabIds = ownedTabIds.filter((tabId) => !group.tabOrder.includes(tabId))' + ) + : current, + resolveDir: resolve(source, '..'), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + alias: { '@': join(root, 'src/renderer/src') } + }) + implementations.push(createRequire(import.meta.url)(outfile).appendOwnedTabIdsToGroups) + } + const rows = [] + for (const count of [1, 10, 100, 1_000, 10_000]) { + for (const missing of [false, true]) { + const ids = Array.from({ length: count }, (_, i) => `tab-${i}`) + const groups = [ + { id: 'group', worktreeId: 'workspace', activeTabId: null, tabOrder: ids, recentTabIds: [] } + ] + const owners = new Map(ids.map((id) => [missing ? `missing-${id}` : id, 'group'])) + assert.deepEqual(implementations[0](groups, owners), implementations[1](groups, owners)) + const iterations = Math.max(1, Math.floor(10_000 / count)) + const samples = [[], []] + for (let sample = -3; sample < 11; sample++) { + for (const index of sample % 2 === 0 ? [0, 1] : [1, 0]) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + implementations[index](groups, owners) + } + const elapsed = (performance.now() - start) / iterations + if (sample >= 0) { + samples[index].push(elapsed) + } + } + } + rows.push({ + count, + missing, + iterations, + beforeMs: samples[0].sort((a, b) => a - b)[5], + afterMs: samples[1].sort((a, b) => a - b)[5] + }) + } + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, samples: 11, warmups: 3, rows }, + null, + 2 + ) + ) +} finally { + await rm(directory, { recursive: true, force: true }) +} diff --git a/config/scripts/benchmark-transcript-reverse-lines.mjs b/config/scripts/benchmark-transcript-reverse-lines.mjs new file mode 100644 index 00000000000..e9d370d1839 --- /dev/null +++ b/config/scripts/benchmark-transcript-reverse-lines.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import Module from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const entry = 'src/shared/agent-hook-listener/transcript-reader.ts' +assert.ok(process.argv[2], 'Pass a pre-change transcript-reader.ts snapshot.') +const baseline = readFileSync(process.argv[2], 'utf8') +assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.') + +async function load(useBaseline) { + const result = await build({ + stdin: { + contents: `export * from './${entry}'; +export { extractAssistantTextFromLine } from './src/shared/agent-hook-listener/transcript-entry-text.ts';`, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent', + plugins: useBaseline + ? [ + { + name: 'baseline-transcript-reader', + setup(builder) { + builder.onLoad({ filter: /transcript-reader\.ts$/ }, () => ({ + contents: baseline, + loader: 'ts' + })) + } + } + ] + : [] + }) + const module = new Module(resolve('transcript-benchmark.cjs')) + module.paths = Module._nodeModulePaths(process.cwd()) + module._compile(result.outputFiles[0].text, module.id) + return module.exports +} + +const versions = [await load(true), await load(false)] +function measure(functions, iterations) { + let sink = 0 + const run = (fn) => { + for (let i = 0; i < iterations; i++) { + sink += fn()?.length ?? 0 + } + } + for (const fn of functions) { + for (let i = 0; i < 3; i++) { + run(fn) + } + } + const samples = [[], []] + for (let round = 0; round < 11; round++) { + for (const index of round % 2 ? [1, 0] : [0, 1]) { + const start = performance.now() + run(functions[index]) + samples[index].push((performance.now() - start) / iterations) + } + } + return { + beforeMs: samples[0].sort((a, b) => a - b)[5], + afterMs: samples[1].sort((a, b) => a - b)[5], + iterations, + sink + } +} + +const cases = [ + ['tiny', `${JSON.stringify({ role: 'assistant', content: 'hello' })}\n`, 10000], + ['64KiB line', `${JSON.stringify({ role: 'assistant', content: 'x'.repeat(65500) })}\n`, 100], + [ + '4MiB line', + `${JSON.stringify({ role: 'assistant', content: 'x'.repeat(4 * 1024 * 1024 - 40) })}\n`, + 10 + ], + [ + '1000 short tool lines', + Array.from({ length: 1000 }, () => + JSON.stringify({ role: 'tool', content: 'x'.repeat(100) }) + ).join('\n'), + 50 + ], + [ + 'Unicode line', + `${JSON.stringify({ role: 'assistant', content: '😀漢字'.repeat(16000) })}\n`, + 100 + ], + [ + 'leading and trailing blank lines', + `\n\r\n${JSON.stringify({ role: 'assistant', content: 'hello' })}\n\n`, + 10000 + ] +] +const directory = mkdtempSync(join(tmpdir(), 'orca-transcript-benchmark-')) +try { + for (const [name, text, iterations] of cases) { + const file = join(directory, 'transcript.jsonl') + writeFileSync(file, text) + const scanners = versions.map( + (v) => () => v.findLastExtractedTranscriptLineText(text, v.extractAssistantTextFromLine) + ) + const readers = versions.map((v) => () => v.readLastAssistantFromTranscriptOnce(file)) + assert.equal(scanners[0](), scanners[1](), name) + assert.equal(readers[0](), readers[1](), name) + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(text), + scanner: measure(scanners, iterations), + warmFileReader: measure(readers, Math.min(iterations, 100)) + }) + ) + } +} finally { + rmSync(directory, { recursive: true, force: true }) +} diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index d3b9db939cd..9243f5a5b78 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -32,6 +32,8 @@ import { import { join, resolve } from 'node:path' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts' import { + ensureWindowsProcessTreeCommandLinePatch, + inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR @@ -89,6 +91,13 @@ function assertPatchApplied() { 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' ) } + if (processCc.includes('OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ')) { + throw new Error( + 'src/process.cc still takes PROCESS_VM_READ for memory or CPU counters it never reads ' + + 'from the address space. pnpm did not apply ' + + 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' + ) + } } // pnpm can materialize this CRLF package without applying its patch. Repair the @@ -123,6 +132,13 @@ function applyWindowsProcessTreeBuildFixes() { '' ) processCc = processCc.replace(/process_count < 1024 && /, '') + // The memory and CPU readers only ever call GetProcessMemoryInfo/GetProcessTimes, + // which need no more than PROCESS_QUERY_LIMITED_INFORMATION; taking VM_READ is + // what EDR scores. + processCc = processCc.replaceAll( + 'OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid)', + 'OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)' + ) if (bindingGyp !== originalBinding) { writeFileSync(bindingPath, bindingGyp) @@ -131,7 +147,8 @@ function applyWindowsProcessTreeBuildFixes() { writeFileSync(processPath, processCc) } stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR) - if (bindingGyp !== originalBinding || processCc !== originalProcess) { + const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR) + if (bindingGyp !== originalBinding || processCc !== originalProcess || repairedCommandLine) { console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.') } } @@ -173,6 +190,14 @@ function main() { if (!existsSync(built)) { throw new Error(`node-gyp reported success but ${built} is missing.`) } + // Why check the artifact and not only the source: the source checks above run + // before node-gyp, and a stale build directory can outlive them. + if (inspectWindowsProcessTreeAddon(built) === 'unpatched') { + throw new Error( + 'The built addon still calls ReadProcessMemory, so it did not come from the patched ' + + 'command-line reader. A relay would get the primitive MDE scores as credential dumping.' + ) + } const machine = readPeMachine(built) if (machine !== PE_MACHINE[arch]) { throw new Error( diff --git a/config/scripts/cli-runtime-client-deferral-equivalence.mjs b/config/scripts/cli-runtime-client-deferral-equivalence.mjs index f443bf3b9f9..a231b5ba75a 100644 --- a/config/scripts/cli-runtime-client-deferral-equivalence.mjs +++ b/config/scripts/cli-runtime-client-deferral-equivalence.mjs @@ -2,7 +2,7 @@ // Equivalence check for deferring the RuntimeClient module graph in the CLI. // // Builds the CLI twice with the REAL tsc emit — once from the working tree and -// once with the seven touched files restored from git HEAD~ (the pre-deferral +// once with the touched files restored from git HEAD~ (the pre-deferral // implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE // across a matrix of invocations. // @@ -13,7 +13,7 @@ // // Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline ] import { execFileSync, spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -21,8 +21,11 @@ const REPO = fileURLToPath(new URL('../..', import.meta.url)) // The files this change touches. Restoring exactly these from the baseline rev // reconstructs the old implementation without disturbing anything else. +// Files absent at the baseline (e.g. cli-error.ts, split out of format.ts +// later) are removed for the baseline build and put back afterwards. const TOUCHED = [ 'src/cli/args.ts', + 'src/cli/cli-error.ts', 'src/cli/dispatch.ts', 'src/cli/flags.ts', 'src/cli/format.ts', @@ -72,12 +75,16 @@ function buildTree(label, baselineRev) { if (baselineRev) { for (const file of TOUCHED) { const path = join(REPO, file) - restored.push([path, readFileSync(path)]) - const old = execFileSync('git', ['show', `${baselineRev}:${file}`], { + restored.push([path, existsSync(path) ? readFileSync(path) : null]) + const old = spawnSync('git', ['show', `${baselineRev}:${file}`], { cwd: REPO, maxBuffer: 64 * 1024 * 1024 }) - writeFileSync(path, old) + if (old.status === 0) { + writeFileSync(path, old.stdout) + } else { + rmSync(path, { force: true }) + } } } execFileSync( @@ -97,7 +104,11 @@ function buildTree(label, baselineRev) { ) } finally { for (const [path, contents] of restored) { - writeFileSync(path, contents) + if (contents === null) { + rmSync(path, { force: true }) + } else { + writeFileSync(path, contents) + } } } return join(outDir, 'cli/index.js') diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index 3412a118491..1732e9a1e8a 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -128,10 +128,14 @@ export async function createDraftRelease({ throw new Error('token is required') } - const previousTag = latestPreviousPublishedDesktopReleaseTag( - await fetchRepoReleases(repo, token, fetchImpl), - tag - ) + const releases = await fetchRepoReleases(repo, token, fetchImpl) + const existingRelease = releases.find((release) => release?.tag_name === tag) + if (existingRelease && existingRelease.draft !== true) { + log(`Release ${tag} already exists and is published.`) + return + } + + const previousTag = latestPreviousPublishedDesktopReleaseTag(releases, tag) const generateNotesBody = { tag_name: tag, target_commitish: tag, @@ -156,24 +160,90 @@ export async function createDraftRelease({ typeof releaseNotes.name === 'string' && releaseNotes.name.length > 0 ? releaseNotes.name : tag const prerelease = tag.includes('-rc.') - // Why: GitHub's generated release notes can exceed the release body API - // limit, so create with a bounded body. Omit target_commitish because the - // release-cut tag already exists and GitHub rejects the tag name there. - await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, { - method: 'POST', - body: JSON.stringify({ - tag_name: tag, - name, - body, - draft: true, - prerelease + if (existingRelease) { + if (!Number.isInteger(existingRelease.id)) { + throw new Error(`Draft release ${tag} is missing a GitHub release id`) + } + // Why: the listing is a snapshot; the draft can be published while notes + // generate, and patching then overwrites a live release body. + const currentRelease = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token + ) + if (currentRelease?.draft !== true) { + log(`Release ${tag} was published while notes were generated; leaving it unchanged.`) + return + } + // Why: the PATCH endpoint supports no conditional/versioned update, so the + // GET above cannot close the window. The PATCH response reports the state we + // actually wrote to; if publication won, put the published body back. + const patchedRelease = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ body }) + } + ) + if (patchedRelease?.draft !== true) { + const publishedBody = typeof currentRelease.body === 'string' ? currentRelease.body : '' + if (publishedBody === body) { + log(`Release ${tag} was published while notes were patched; its body is unchanged.`) + return + } + // Why: the rollback must not clobber a body written after our PATCH, so + // restore only while the release still carries exactly what we wrote. + const releaseBeforeRollback = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token + ) + if (releaseBeforeRollback?.body !== body) { + log( + `Release ${tag} was published and its body changed again while notes were patched; leaving the newer body in place.` + ) + return + } + await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ body: publishedBody }) + } + ) + log( + `Release ${tag} was published while notes were patched; restored its published body and left the generated notes unapplied.` + ) + return + } + } else { + // Why: GitHub's generated release notes can exceed the release body API + // limit, so create with a bounded body. Omit target_commitish because the + // release-cut tag already exists and GitHub rejects the tag name there. + await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, { + method: 'POST', + body: JSON.stringify({ + tag_name: tag, + name, + body, + draft: true, + prerelease + }) }) - }) + } if (generatedBody.length !== body.length) { - log(`Created draft release ${tag} with truncated generated notes (${body.length} chars).`) + log( + `${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with truncated generated notes (${body.length} chars).` + ) } else { - log(`Created draft release ${tag} with generated notes (${body.length} chars).`) + log( + `${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with generated notes (${body.length} chars).` + ) } } diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index b330ccb9423..911ac00be63 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -132,7 +132,7 @@ describe('createDraftRelease', () => { it('creates a draft release with bounded generated notes', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.35')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -184,7 +184,7 @@ describe('createDraftRelease', () => { it('marks rc tags as prereleases', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.36')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true })) @@ -200,10 +200,136 @@ describe('createDraftRelease', () => { expect(createBody.prerelease).toBe(true) }) + it('regenerates notes for an existing draft release', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'stale' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'notes' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenNthCalledWith( + 3, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.not.objectContaining({ method: expect.anything() }) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 4, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ body: 'notes' }) }) + ) + }) + + it('skips the update when the draft was published while notes were generated', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(fetchImpl).toHaveBeenNthCalledWith( + 3, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.not.objectContaining({ method: expect.anything() }) + ) + }) + + it('restores the published body when publication lands between the check and the patch', async () => { + const log = vi.fn() + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'hand-written notes' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log + }) + + expect(fetchImpl).toHaveBeenCalledTimes(6) + expect(fetchImpl).toHaveBeenNthCalledWith( + 6, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ body: 'hand-written notes' }) + }) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('restored its published body')) + }) + + it('leaves a body written after the patch in place instead of rolling it back', async () => { + const log = vi.fn() + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'newer published body' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log + }) + + expect(fetchImpl).toHaveBeenCalledTimes(5) + expect(log).toHaveBeenCalledWith(expect.stringContaining('leaving the newer body in place')) + }) + + it('preserves notes on an existing published release', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([release('v1.4.36', { id: 42 })])) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')])) + .mockResolvedValueOnce(jsonResponse([release('mobile-v0.0.12')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs index 7ae3b1c9428..58f6f8d8865 100644 --- a/config/scripts/electron-builder-markdown-associations.test.mjs +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -103,14 +103,24 @@ describe('electron-builder markdown file associations', () => { // 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. + // sweep would silently orphan a running daemon host on every uninstall. + // + // Asserted against comment-stripped script, and on the app exe name first: the relocated + // host is a verbatim copy of the app exe (daemonHostExeName, daemon-host-relocation.ts), + // so a macro that kills only orca-terminal-daemon.exe matches no running process. The + // prose above the macro names both, so a toContain over the raw file proves nothing. it('keeps the daemon-host uninstall sweep across the include rename', async () => { - const hooks = await readInstallerHooks() + const script = stripNsisCommentLines(await readInstallerHooks()) - expect(hooks).toContain('orca-terminal-daemon.exe') - expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host') + expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?\$\{APP_EXECUTABLE_FILENAME\}"?/) + // Legacy name, so hosts left by builds that renamed the copy still get reaped. + expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?orca-terminal-daemon\.exe"?/) + // Scopes both kills to the uninstalling user: an elevated machine-wide uninstall must + // not reach another logged-on user's session. + expect(script).toMatch(/\/FI\s+"USERNAME eq /) + expect(script).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\}/) + expect(script).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) }) }) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index a4cc6db8843..b2a47b99d5b 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -5,6 +5,12 @@ import { createRequire } from 'node:module' import { existsSync, readFileSync } from 'node:fs' import { release } from 'node:os' import { basename, dirname, resolve } from 'node:path' +import { + ensureWindowsProcessTreeCommandLinePatch, + inspectWindowsProcessTreeAddon, + stageWindowsProcessTreeNodeAddonApiHeaders, + windowsProcessTreeAddonPath +} from './windows-process-tree-gyp-rebuild.mjs' const require = createRequire(import.meta.url) const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') @@ -253,11 +259,18 @@ function collectNativeModuleFailures() { function loadNativeModule(moduleName) { if (moduleName === '@vscode/windows-process-tree') { - // A bare require already loads the .node addon on win32, so it catches an - // ABI mismatch on its own. What it cannot catch is a snapshot that comes - // back empty -- the shape a blocked CreateToolhelp32Snapshot produces -- - // so check the addon actually enumerates before calling the runtime healthy. + // A bare require loads the .node addon on win32, so it catches an ABI + // mismatch on its own. What it cannot catch is *which* addon loaded: the + // published tarball ships a prebuilt built from unpatched source that is + // node-addon-api, so it requires cleanly and then reads every process's + // command line out of its address space. Check the binary, not the load. require(moduleName) + if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath()) === 'unpatched') { + throw new Error( + 'the loaded addon still calls ReadProcessMemory, so it was not built from the patched ' + + 'source. Rebuild it (pnpm run rebuild:electron) rather than using the published prebuild.' + ) + } return } if (moduleName === 'windows-native-registry') { @@ -368,6 +381,14 @@ function getWindowsBuildNumber() { function rebuildNodeRuntimeModules(moduleNames) { for (const moduleName of moduleNames) { const moduleDir = dirname(require.resolve(`${moduleName}/package.json`)) + if (moduleName === '@vscode/windows-process-tree') { + // Why before node-gyp: this module is rebuilt precisely because the + // binary was the unpatched one, and pnpm materializes it unpatched often + // enough that compiling the source as-is would just rebuild the same + // reader and fail the verify pass. + ensureWindowsProcessTreeCommandLinePatch(moduleDir) + stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir) + } console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir }) if (moduleName === 'node-pty' && process.platform === 'win32') { diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index ea6e876e619..973e2f6852d 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) const sourceNodePtyJobOwnershipPath = fileURLToPath( @@ -27,7 +28,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeFakeNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) @@ -67,7 +67,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true }) writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) @@ -102,7 +101,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) @@ -137,7 +135,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) @@ -171,7 +168,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' }) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) @@ -198,7 +194,9 @@ describe('ensure-native-runtime', () => { function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) - mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + // Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture + // missing it fails every case with a module-resolution error instead of the defect under test. + copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) copyFileSync( sourceNodePtyJobOwnershipPath, join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') diff --git a/config/scripts/file-explorer-deletion-roots-benchmark.mjs b/config/scripts/file-explorer-deletion-roots-benchmark.mjs new file mode 100644 index 00000000000..d276964861b --- /dev/null +++ b/config/scripts/file-explorer-deletion-roots-benchmark.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const bundled = await build({ + stdin: { + contents: `export { selectDeletionRoots } from './file-explorer-batch-deletion'; + export { isPathEqualOrDescendant } from './file-explorer-paths';`, + resolveDir: join(root, 'src/renderer/src/components/right-sidebar'), + loader: 'ts' + }, + alias: { '@': join(root, 'src/renderer/src') }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent' +}) +const { selectDeletionRoots, isPathEqualOrDescendant } = await import( + `data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}` +) + +// Original production selector; both paths use the same path-comparison implementation. +function original(nodes) { + return nodes.filter( + (n) => + !nodes.some( + (other) => other !== n && other.isDirectory && isPathEqualOrDescendant(n.path, other.path) + ) + ) +} + +function measure(run, nodes) { + for (let index = 0; index < 3; index++) { + run(nodes) + } + const samples = [] + for (let index = 0; index < 11; index++) { + const start = performance.now() + run(nodes) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[5] +} + +const results = [] +for (const [fileCount, directoryCount] of [ + [100, 0], + [1000, 0], + [5000, 0], + [5000, 5], + [0, 100] +]) { + const nodes = Array.from({ length: fileCount + directoryCount }, (_, index) => ({ + name: `item-${index}`, + path: `/repo/item-${index}`, + relativePath: `item-${index}`, + isDirectory: index >= fileCount, + depth: 0 + })) + const expected = original(nodes) + const actual = selectDeletionRoots(nodes) + assert.equal(actual.length, expected.length) + actual.forEach((node, index) => assert.equal(node, expected[index])) + results.push({ + fileCount, + directoryCount, + beforeMs: measure(original, nodes), + afterMs: measure(selectDeletionRoots, nodes) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/mobile-file-ranking-benchmark.mjs b/config/scripts/mobile-file-ranking-benchmark.mjs new file mode 100644 index 00000000000..68ac5b9d977 --- /dev/null +++ b/config/scripts/mobile-file-ranking-benchmark.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/mobile-file-ranking-benchmark.mjs ') +} +async function load(source) { + const js = stripTypeScriptTypes(source, { mode: 'transform' }) + return await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +} +function measure(fn, paths, query) { + for (let warmup = 0; warmup < 10; warmup++) { + fn(paths, query, 16) + } + const samples = [] + for (let i = 0; i < 9; i++) { + const start = performance.now() + fn(paths, query, 16) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[4] +} +const results = [] +for (const [file, name] of [ + ['src/main/runtime/runtime-mobile-file-path-search.ts', 'rankRuntimeMobileFilePaths'], + ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions'] +]) { + const before = ( + await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })) + )[name] + const after = (await load(readFileSync(file, 'utf8')))[name] + for (const count of [100, 100000]) { + const paths = Array.from( + { length: count }, + (_, i) => `src/components/workspace/group-${i % 100}/file-${i}.tsx` + ) + for (const query of ['file-9', 'missing', 'workspace']) { + assert.deepEqual(after(paths, query, 16), before(paths, query, 16)) + results.push({ + function: name, + paths: count, + query, + beforeMs: measure(before, paths, query), + afterMs: measure(after, paths, query) + }) + } + } +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/mobile-markdown-placeholder-benchmark.mjs b/config/scripts/mobile-markdown-placeholder-benchmark.mjs new file mode 100644 index 00000000000..20280e5a8d2 --- /dev/null +++ b/config/scripts/mobile-markdown-placeholder-benchmark.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const sourcePath = 'mobile/src/components/mobile-markdown-preview-html.ts' +const baselineRef = process.argv[2] +if (!baselineRef) { + throw new Error( + 'Usage: node config/scripts/mobile-markdown-placeholder-benchmark.mjs ' + ) +} +async function load(source) { + const result = await build({ + stdin: { contents: source, resolveDir: dirname(resolve(sourcePath)), loader: 'ts' }, + bundle: true, + write: false, + platform: 'node', + format: 'esm' + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).normalizeMobileMarkdownPreviewHtml +} +const before = await load( + execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(sourcePath, 'utf8')) +function measure(fn, input, repeats) { + const samples = [] + for (let run = 0; run < repeats; run++) { + const start = performance.now() + fn(input) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)] +} +const results = [] +for (const [shape, input] of [ + ['ordinary Markdown', '# Hello\n\n

Use `Array` and bold.

'], + ...[2048, 8192, 16384].map((length) => [ + `${length} underscore collision`, + `\uE000ORCA_MD_CODE_${'_'.repeat(length)}0\uE000 and \`Array\`` + ]) +]) { + assert.equal(after(input), before(input)) + results.push({ + shape, + bytes: Buffer.byteLength(input), + beforeMs: measure(before, input, 5), + afterMs: measure(after, input, 15) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/patched-dependencies-frozen-install.test.mjs b/config/scripts/patched-dependencies-frozen-install.test.mjs new file mode 100644 index 00000000000..89f98ef074b --- /dev/null +++ b/config/scripts/patched-dependencies-frozen-install.test.mjs @@ -0,0 +1,155 @@ +import { + cpSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join, parse, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../src/shared/child-process/run-process.ts' +import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' + +/** + * Run the command that actually consumes the patch hashes. + * + * A hash comparison is not this check. `@vscode/windows-process-tree@0.8.0` shipped + * twice with a hand-computed `sha256(patchBytes)` in the lockfile, and two separate + * reviews "verified" it by recomputing the same number the same wrong way. pnpm + * hashes the **LF-normalized** content, so a CRLF patch makes the raw digest a value + * pnpm will never produce, and `--frozen-lockfile` dies with + * ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on every runner. An independent check that + * repeats the original assumption is not independent; only the installer is. + * + * `--lockfile-only --ignore-scripts` keeps it to the resolution pnpm rejects on, + * with no node_modules and no native builds. + */ +const PROJECT_DIR = resolve(import.meta.dirname, '../..') +const WINDOWS_PROCESS_TREE_PATCH = '@vscode__windows-process-tree@0.8.0.patch' + +/** + * Which pnpm to run belongs to pnpm-cli-invocation.mjs, not to this file: naming + * the Windows shim here is what windows-cmd-shim-spawn-boundary.test.mjs rejects. + * Its `shell` is dropped on purpose -- runProcessSync refuses that flag and + * already drives a shim through the interpreter itself. + */ +function resolvePnpmInvocation() { + const { command, prefixArgs } = resolvePnpmCliInvocation() + if (isAbsolute(command)) { + return existsSync(command) ? { program: command, prefixArgs } : null + } + // Bare name only when npm_execpath is unset (bare `vitest`, not `pnpm test`). + // Drop the extension so the shared resolver tries every executable form of it. + const resolved = resolveCliCommand(parse(command).name) + return isAbsolute(resolved) ? { program: resolved, prefixArgs } : null +} + +describe('patched dependencies', () => { + it('installs with --frozen-lockfile, which is what validates every patch hash', () => { + const pnpm = resolvePnpmInvocation() + expect(pnpm, 'pnpm must be installed; it is the only thing that can check this').not.toBeNull() + + // A copy, because a --frozen-lockfile run still rewrites parts of the + // lockfile this repo does not track, and the real one must not move. + const scratch = mkdtempSync(join(tmpdir(), 'orca-frozen-install-')) + try { + for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { + copyFileSync(join(PROJECT_DIR, file), join(scratch, file)) + } + mkdirSync(join(scratch, 'config'), { recursive: true }) + cpSync(join(PROJECT_DIR, 'config', 'patches'), join(scratch, 'config', 'patches'), { + recursive: true + }) + + const result = runProcessSync({ + program: pnpm.program, + args: [ + ...pnpm.prefixArgs, + 'install', + '--frozen-lockfile', + '--lockfile-only', + '--ignore-scripts' + ], + cwd: scratch, + timeoutMs: 300_000 + }) + + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0) + } finally { + removeTreeSync(scratch) + } + // The 300s spawn budget is only reachable if the case is allowed to take it; + // config/vitest.config.ts caps every case at 30s by default. + }, 300_000) + + /** + * `--lockfile-only` resolves; it never applies a patch. So the case above is + * bounded to hash consistency, and the actual question -- can pnpm still put + * the patched reader on disk? -- had nothing covering it. + * + * One package, patch applied for real, assert the marker landed. Scoped to the + * single dependency so it stays a ~2s check rather than a full install. + */ + it('materializes the patched command-line reader on a real install', () => { + const pnpm = resolvePnpmInvocation() + expect(pnpm, 'pnpm must be installed; it is the only thing that can check this').not.toBeNull() + + const scratch = mkdtempSync(join(tmpdir(), 'orca-patch-apply-')) + try { + mkdirSync(join(scratch, 'config', 'patches'), { recursive: true }) + copyFileSync( + join(PROJECT_DIR, 'config', 'patches', WINDOWS_PROCESS_TREE_PATCH), + join(scratch, 'config', 'patches', WINDOWS_PROCESS_TREE_PATCH) + ) + writeFileSync( + join(scratch, 'package.json'), + `${JSON.stringify( + { + name: 'orca-patch-apply-probe', + version: '1.0.0', + dependencies: { '@vscode/windows-process-tree': '0.8.0' } + }, + null, + 2 + )}\n` + ) + writeFileSync( + join(scratch, 'pnpm-workspace.yaml'), + 'packages: []\n' + + 'patchedDependencies:\n' + + ` '@vscode/windows-process-tree@0.8.0': config/patches/${WINDOWS_PROCESS_TREE_PATCH}\n` + ) + + const result = runProcessSync({ + program: pnpm.program, + args: [...pnpm.prefixArgs, 'install', '--no-frozen-lockfile', '--ignore-scripts'], + cwd: scratch, + timeoutMs: 300_000 + }) + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0) + + const materialized = readFileSync( + join( + scratch, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'src', + 'process_commandline.cc' + ), + 'utf8' + ) + expect(materialized).toContain('kProcessCommandLineInformation') + // The whole point of the patch: the upstream reader is gone, not merely + // supplemented. + expect(materialized).not.toContain('ReadProcessMemory') + } finally { + removeTreeSync(scratch) + } + }, 300_000) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index fd36a803bb9..3089d376b2a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -213,13 +213,17 @@ const LINUX_PACKAGE_TESTS = [ const WINDOWS_PACKAGE_TESTS = [ ...LINUX_PACKAGE_TESTS, 'config/scripts/rebuild-native-deps.test.mjs', + 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', 'src/shared/child-process/windows-command-line.win32.test.ts', + 'src/shared/child-process/windows-cmd-shim-resolution.test.ts', + 'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts', 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', 'src/main/windows/windows-pty-job.win32.test.ts', 'src/main/windows/windows-host-job.win32.test.ts', + 'src/main/windows/windows-process-tree-command-line-patch.test.ts', 'src/main/windows-live-tree-kill.win32.test.ts', 'src/main/wsl/wsl-runner.test.ts', 'src/main/wsl/wsl-guest-environment.test.ts', @@ -228,14 +232,18 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/wsl/wsl-w1-w3-contract.test.ts', 'src/shared/source-scan/source-tree-scan.test.ts', 'src/main/cli/wsl-cli-powershell-boundary.test.ts', + 'src/main/computer/desktop-script-runtime-host.win32.test.ts', 'src/main/cursor/hook-service.test.ts', 'src/main/orca-profiles/profile-index-store.test.ts', 'src/main/startup/windows-install-dir-acl-repair.win32.test.ts', 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts', 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts', 'src/shared/secure-file-fsync-flags.test.ts', + 'src/shared/secure-path-windows-acl.win32.test.ts', + 'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts', 'src/main/ipc/pty-codex-account-attribution.test.ts', - 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts' + 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', + 'src/relay/windows-port-scan.win32.test.ts' ] const DESKTOP_IRRELEVANT_PREFIXES = [ diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 67e271868df..41f9338ab75 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -636,13 +636,8 @@ describe('PR E2E gate contract', () => { .filter((spec) => nativeGateExpression.test(readFileSync(join(projectDir, spec), 'utf8'))) expect(nativeGatedSpecs.length).toBeGreaterThan(0) - // Why exempt: the digit repro needs a nested gnome-shell, which no hosted runner provides - // (headless mutter never answers RemoteDesktop.CreateSession); the macOS spec needs a real - // macOS input source, and no macOS runner exists on any PR or scheduled lane. - const unreachableSpecs = new Set([ - 'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts', - 'tests/e2e/terminal-macos-2set-korean-native.spec.ts' - ]) + // The macOS spec needs a native input source; PR and scheduled IME lanes use Linux. + const unreachableSpecs = new Set(['tests/e2e/terminal-macos-2set-korean-native.spec.ts']) const unclaimed = nativeGatedSpecs.filter( (spec) => !unreachableSpecs.has(spec) && !nativeImeRunner.includes(spec) ) @@ -682,8 +677,13 @@ describe('PR E2E gate contract', () => { // Why pin the titles: the runner requires one receipt per name, so a rename that nobody // mirrored here would fail the lane loudly instead of quietly halving it. + const nativeDigitSpec = readFileSync( + join(projectDir, 'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts'), + 'utf8' + ) + expect(nativeDigitSpec).toContain('appendImeEngagementReceipt(testInfo.title, trace)') for (const title of EXPECTED_NATIVE_IME_TESTS) { - expect(nativeImeSpec, title).toContain(title) + expect(nativeImeSpec + nativeDigitSpec, title).toContain(title) } }) diff --git a/config/scripts/quick-open-exclusion-benchmark.mjs b/config/scripts/quick-open-exclusion-benchmark.mjs new file mode 100644 index 00000000000..399302c5a2b --- /dev/null +++ b/config/scripts/quick-open-exclusion-benchmark.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const bundled = await build({ + entryPoints: ['src/shared/quick-open-filter.ts'], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent' +}) +const { shouldExcludeQuickOpenRelPath: after } = await import( + `data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}` +) +// Original production predicate, including its exact boundary check. +function before(relPath, prefixes) { + for (const prefix of prefixes) { + if (relPath === prefix) { + return true + } + if (relPath.length > prefix.length && relPath.startsWith(`${prefix}/`)) { + return true + } + } + return false +} +const files = Array.from( + { length: 100000 }, + (_, index) => `src/components/group-${index % 100}/file-${index}.tsx` +) +function run(fn, prefixes) { + let excluded = 0 + for (const file of files) { + excluded += Number(fn(file, prefixes)) + } + return excluded +} +function measure(fn, prefixes) { + run(fn, prefixes) + const samples = [] + for (let index = 0; index < 5; index++) { + const start = performance.now() + run(fn, prefixes) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[2] +} +const results = [] +for (const count of [0, 10, 100, 500]) { + const prefixes = Array.from({ length: count }, (_, index) => `nested-worktrees/worktree-${index}`) + assert.equal(run(after, prefixes), run(before, prefixes)) + results.push({ + files: files.length, + exclusions: count, + beforeMs: measure(before, prefixes), + afterMs: measure(after, prefixes) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index 09e38853371..871732dd53d 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -4,6 +4,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + gitLineEndingEnv, + initGitWorkTree, mkTempProject, runRebuildScript, writeFakeElectronRebuild, @@ -14,7 +16,8 @@ import { writeFakeWindowsProcessTreeWithNodeAddonApi, writeFakeWindowsRegistry, writeNodePtyPatchFile, - writePatchedNodePtyBuildArtifacts + writePatchedNodePtyBuildArtifacts, + writeWindowsProcessTreePatchFile } from './rebuild-native-deps-test-fixtures.mjs' describe('rebuild-native-deps patched node-pty rebuild', () => { @@ -85,6 +88,91 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } }) + const commandLineSourcePath = (projectDir) => + join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'src', + 'process_commandline.cc' + ) + + // Why inside a git work tree: `git apply` run under one prefixes patch paths + // with the cwd-relative prefix, silently skips what does not match, and still + // exits 0. The package dir is always under the project root in production, so + // a fixture in %TEMP% alone would pass while the real repair did nothing. + // + // Why both line-ending modes: the patch is stored LF while upstream ships this + // source CRLF, so whether the pre-image matches depends on `core.autocrlf` -- + // and under `false`, Git's own built-in default, it did not. The repair blinds + // git to the repo, so that value comes from global config, i.e. from whichever + // option the developer's installer wrote. Pinning both makes the case cover the + // host that breaks rather than the host that happens to run it. + for (const autocrlf of ['false', 'true']) { + it(`repairs an un-applied command-line patch in a work tree (autocrlf=${autocrlf})`, () => { + const projectDir = mkTempProject() + + try { + initGitWorkTree(projectDir) + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { + commandLinePatchApplied: false + }) + writeWindowsProcessTreePatchFile(projectDir) + + const result = runRebuildScript( + projectDir, + { + npm_config_platform: 'win32', + npm_config_arch: 'x64', + ...gitLineEndingEnv(autocrlf) + }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).toContain( + 'kProcessCommandLineInformation' + ) + } finally { + removeTreeSync(projectDir) + } + }) + } + + // Why fail rather than build: an unpatched command-line reader compiles fine + // and then opens every process with PROCESS_VM_READ to walk its PEB, which is + // the primitive the patch exists to remove. + it('refuses a Windows rebuild when the command-line patch cannot be applied', () => { + const projectDir = mkTempProject() + + try { + initGitWorkTree(projectDir) + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { commandLinePatchApplied: false }) + // No patch file, so the repair has nothing to apply. + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('process_commandline.cc') + expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).not.toContain( + 'kProcessCommandLineInformation' + ) + } finally { + removeTreeSync(projectDir) + } + }) + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { const projectDir = mkTempProject() @@ -256,4 +344,37 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } } ) + + // The binary this step produces is the one copied into the packaged app. The + // relay build checks its own artifact and ensure-native-runtime checks what it + // loads; nothing checked this one, so a rebuild that quietly emitted the + // upstream reader shipped. Both non-clean states have to fail, which is the + // caller the tri-state was missing: after a rebuild that reported success, an + // absent binary is a broken build, not an absence to shrug at. + for (const [addon, expected] of [ + ['unpatched', 'still imports ReadProcessMemory'], + ['none', 'is not there'] + ]) { + it(`fails a Windows rebuild that leaves ${addon} windows-process-tree bytes`, () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { addon }) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(expected) + } finally { + removeTreeSync(projectDir) + } + }) + } }) diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 585e7a58ef2..2cb7d8ba8b4 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -1,5 +1,12 @@ import { spawnSync } from 'node:child_process' -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,6 +22,68 @@ const sourceNodePtyJobOwnershipPath = fileURLToPath( const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath( new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url) ) +const sourceWindowsProcessTreePatchPath = fileURLToPath( + new URL('../patches/@vscode__windows-process-tree@0.8.0.patch', import.meta.url) +) + +/** + * The command-line reader as it is *before* the patch, taken from the patch's + * own pre-image so no upstream copy has to be vendored. + * + * Written back as **CRLF**, which is what `@vscode/windows-process-tree@0.8.0` + * actually ships: all 67 pre-image lines of this file carried a CR before the + * patch was normalized to LF. Rebuilding it with the patch's current newline + * instead would make fixture and patch agree by construction, on any encoding — + * which is exactly how a repair that cannot apply to the real package passed + * this suite. + */ +function unpatchedWindowsProcessTreeCommandLineSource() { + const lines = readFileSync(sourceWindowsProcessTreePatchPath, 'utf8').split('\n') + const start = lines.findIndex((line) => + line.startsWith('diff --git a/src/process_commandline.cc ') + ) + const rest = lines.slice(start + 1) + const end = rest.findIndex((line) => line.startsWith('diff --git ')) + const preImage = (end === -1 ? rest : rest.slice(0, end)) + .filter((line) => line.startsWith(' ') || line.startsWith('-')) + .filter((line) => !line.startsWith('---')) + .map((line) => line.slice(1).replace(/\r$/, '')) + .join('\r\n') + // Splitting drops the file's own trailing newline as an empty element, and + // `git apply` needs the bytes exact. + return `${preImage}\r\n` +} + +/** + * Pin `core.autocrlf` for a spawned repair, whatever the host is set to. + * + * The repair blinds git to the surrounding repo with `GIT_DIR`, so the value it + * sees comes from global/system config — on a Git for Windows box that is + * whichever line-ending option the installer wrote, and `false` (Git's built-in + * default, "checkout as-is") is the one the repair used to fail under. A global + * config in a temp HOME outranks the system file, so this is deterministic + * rather than whatever the developer happens to have. + */ +export function gitLineEndingEnv(autocrlf) { + const home = mkdtempSync(join(tmpdir(), `orca-git-home-${autocrlf}-`)) + writeFileSync(join(home, '.gitconfig'), `[core]\n\tautocrlf = ${autocrlf}\n`) + return { HOME: home, USERPROFILE: home } +} + +/** Production always runs the repair from inside a work tree; `git apply` behaves differently there. */ +export function initGitWorkTree(projectDir) { + for (const args of [['init'], ['config', 'user.email', 'a@b.c'], ['config', 'user.name', 't']]) { + spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' }) + } +} + +export function writeWindowsProcessTreePatchFile(projectDir) { + mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) + copyFileSync( + sourceWindowsProcessTreePatchPath, + join(projectDir, 'config', 'patches', '@vscode__windows-process-tree@0.8.0.patch') + ) +} export function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) @@ -143,17 +212,46 @@ if (${JSON.stringify(createExecutable)}) { ) } -export function writeFakeElectronRebuild(projectDir, { logPathEnv = null } = {}) { +/** Bytes that stand in for a compiled addon's import table. */ +const FAKE_ADDON_BYTES = { + clean: 'MZ\0ntdll.dll\0NtQueryInformationProcess\0', + unpatched: 'MZ\0KERNEL32.dll\0ReadProcessMemory\0' +} + +/** + * A rebuild that produces nothing leaves no addon to inspect, and the script now + * asserts the binary it just built is a patched one. Emit a stand-in so the + * fixture models a rebuild that actually succeeded. `addon` picks which kind, + * because "produced the upstream reader" and "produced nothing" are both real + * outcomes that assertion has to tell apart. + */ +export function writeFakeElectronRebuild(projectDir, { logPathEnv = null, addon = 'clean' } = {}) { const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild') mkdirSync(rebuildDir, { recursive: true }) writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' })) + const emitAddon = + addon === 'none' + ? '' + : ` + const packageDir = join('node_modules', '@vscode', 'windows-process-tree') + if (existsSync(join(packageDir, 'package.json'))) { + mkdirSync(join(packageDir, 'build', 'Release'), { recursive: true }) + writeFileSync( + join(packageDir, 'build', 'Release', 'windows_process_tree.node'), + ${JSON.stringify(FAKE_ADDON_BYTES[addon])} + ) + }` + const emitImports = + addon === 'none' + ? '' + : "import { existsSync, mkdirSync, writeFileSync } from 'node:fs'\nimport { join } from 'node:path'\n" writeFileSync( join(rebuildDir, 'index.js'), logPathEnv ? ` import { appendFileSync } from 'node:fs' - -export async function rebuild(options) { +${emitImports} +export async function rebuild(options) {${emitAddon} const logPath = process.env[${JSON.stringify(logPathEnv)}] if (!logPath) { return @@ -171,7 +269,10 @@ export async function rebuild(options) { ) } ` - : 'export async function rebuild() {}\n' + : `${emitImports} +export async function rebuild() {${emitAddon} +} +` ) } @@ -271,12 +372,22 @@ export function writeFakeWindowsProcessTree(projectDir) { writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') } -export function writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) { +export function writeFakeWindowsProcessTreeWithNodeAddonApi( + projectDir, + { commandLinePatchApplied = true } = {} +) { const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api') mkdirSync(nodeAddonApiDir, { recursive: true }) writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n') writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + mkdirSync(join(processTreeDir, 'src'), { recursive: true }) + writeFileSync( + join(processTreeDir, 'src', 'process_commandline.cc'), + commandLinePatchApplied + ? '// kProcessCommandLineInformation = 60\n' + : unpatchedWindowsProcessTreeCommandLineSource() + ) writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n') writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n') writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') diff --git a/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs b/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs new file mode 100644 index 00000000000..4f98c1b092d --- /dev/null +++ b/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs @@ -0,0 +1,103 @@ +import { spawn } from 'node:child_process' +import { appendFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' + +import { + mkTempProject, + runRebuildScript, + writeFakeElectronRebuild, + writeFakeNodePtyConptyPayload, + writeFakeUsableElectronPackage, + writeFakeWindowsProcessTreeWithNodeAddonApi +} from './rebuild-native-deps-test-fixtures.mjs' + +const require = createRequire(import.meta.url) + +/** A real loadable addon, so the OS holds the same lock a running Orca holds. */ +function repoAddonPath() { + try { + const entry = require.resolve('@vscode/windows-process-tree') + const built = join(entry, '..', '..', 'build', 'Release', 'windows_process_tree.node') + return existsSync(built) ? built : null + } catch { + return null + } +} + +/** + * Stage a stale addon and keep it loaded, exactly as a running Orca does. + * + * The bytes are the repo's own patched build with the flagged import appended, + * because the guard keys on that symbol and the patched binary does not carry + * it. Trailing bytes are PE overlay, so the file still loads. + */ +async function stageLoadedStaleAddon(projectDir) { + const source = repoAddonPath() + const releaseDir = join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'build', + 'Release' + ) + mkdirSync(releaseDir, { recursive: true }) + const stale = join(releaseDir, 'windows_process_tree.node') + copyFileSync(source, stale) + appendFileSync(stale, 'ReadProcessMemory') + + const holder = spawn( + process.execPath, + ['-e', 'require(process.argv[1]); process.send("held"); setInterval(() => {}, 1000)', stale], + { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] } + ) + await new Promise((resolve, reject) => { + holder.once('message', resolve) + holder.once('exit', () => reject(new Error('the addon holder exited before loading'))) + }) + return holder +} + +// Why an end-to-end run: the defect was purely one of placement. The guard threw +// a real EPERM, and the classifier that turns that into "close running Orca" +// already existed -- the throw simply happened before the try that reaches it. +// Only the whole script exercises that. +describe.runIf(process.platform === 'win32')('rebuild-native-deps stale addon under lock', () => { + it.skipIf(!repoAddonPath())( + 'reports a locked stale addon as a Windows file lock instead of an EPERM stack', + async () => { + const projectDir = mkTempProject() + let holder + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + holder = await stageLoadedStaleAddon(projectDir) + + const result = runRebuildScript( + projectDir, + { + npm_lifecycle_event: 'postinstall', + npm_config_platform: 'win32', + npm_config_arch: process.arch + }, + ['--platform=win32', `--arch=${process.arch}`, '--force'] + ) + + expect(result.stderr).toContain( + 'Close running Orca/Electron/dev processes for this worktree' + ) + // Non-strict postinstall soft-exits on a lock; the next dev/start re-checks. + expect(result.status, result.stderr).toBe(0) + } finally { + holder?.kill() + removeTreeSync(projectDir) + } + } + ) +}) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 3b17683e831..863aac850a1 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -20,7 +20,12 @@ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' -import { stageWindowsProcessTreeNodeAddonApiHeaders } from './windows-process-tree-gyp-rebuild.mjs' +import { + ensureWindowsProcessTreeCommandLinePatch, + inspectWindowsProcessTreeAddon, + stageWindowsProcessTreeNodeAddonApiHeaders, + windowsProcessTreeAddonPath +} from './windows-process-tree-gyp-rebuild.mjs' import { copyFileSync, existsSync, @@ -141,15 +146,21 @@ if (!ignoreModules.includes('cpu-features')) { } } -if ( - rebuildPlatform === 'win32' && - modulesToRebuild.includes('@vscode/windows-process-tree') && - existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')) -) { - stageWindowsProcessTreeNodeAddonApiHeaders() -} - try { + // Why inside the try: the patch guard deletes a stale addon binary, and that + // delete fails EPERM when the addon is loaded -- exactly the running-Orca case + // the catch below is written for. Outside, it aborted `pnpm install` with a + // raw stack instead of the "close running Orca/Electron processes" message. + if ( + rebuildPlatform === 'win32' && + modulesToRebuild.includes('@vscode/windows-process-tree') && + existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')) + ) { + stageWindowsProcessTreeNodeAddonApiHeaders() + if (ensureWindowsProcessTreeCommandLinePatch()) { + console.warn('[rebuild] Repaired the un-applied windows-process-tree command-line patch.') + } + } await rebuild({ buildPath: projectDir, electronVersion, @@ -165,6 +176,7 @@ try { force: true }) restoreNodePtyWindowsConptyRuntime() + assertWindowsProcessTreeAddonIsPatched() } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) if (isWindowsNativeLockError(err)) { @@ -184,6 +196,40 @@ try { process.exit(1) } +/** + * The binary this rebuild just produced is the one the packaged app ships. + * + * The relay build asserts its own artifact and `ensure-native-runtime.mjs` + * asserts what it loads, but nothing checked the addon that gets copied into the + * packaged `node_modules` -- so a rebuild that silently produced the upstream + * reader would reach users. Anything but `clean` fails: after a rebuild that + * reported success the binary must exist, so `missing` is a broken build, not an + * absence to shrug at. This is the caller that needs the state to be a state and + * not a boolean. + */ +function assertWindowsProcessTreeAddonIsPatched() { + if ( + rebuildPlatform !== 'win32' || + !modulesToRebuild.includes('@vscode/windows-process-tree') || + !existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')) + ) { + return + } + const addonPath = windowsProcessTreeAddonPath() + const state = inspectWindowsProcessTreeAddon(addonPath) + if (state === 'clean') { + return + } + throw new Error( + state === 'missing' + ? `the rebuild reported success but ${addonPath} is not there, so the packaged app would ` + + 'ship no windows-process-tree addon at all.' + : `${addonPath} still imports ReadProcessMemory, so it was not built from the patched ` + + 'command-line reader. The packaged app would carry the primitive MDE scores as ' + + 'credential dumping.' + ) +} + function restoreNodePtyWindowsConptyRuntime() { if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) { return diff --git a/config/scripts/redactor-environment-lines-benchmark.mjs b/config/scripts/redactor-environment-lines-benchmark.mjs new file mode 100644 index 00000000000..71aebf9fe88 --- /dev/null +++ b/config/scripts/redactor-environment-lines-benchmark.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' +import { redactString } from '../../src/main/observability/redactor.ts' + +// Supply an unchanged redactor.ts snapshot to measure the actual previous production function. +const baselinePath = process.argv[2] +if (!baselinePath) { + throw new Error( + 'Usage: node config/scripts/redactor-environment-lines-benchmark.mjs ' + ) +} +const baselineSource = stripTypeScriptTypes(readFileSync(baselinePath, 'utf8')) +const { redactString: before } = await import( + `data:text/javascript;base64,${Buffer.from(baselineSource).toString('base64')}` +) +function median(fn, input, repeats) { + const samples = [] + for (let run = 0; run < repeats; run++) { + const started = performance.now() + fn(input) + samples.push(performance.now() - started) + } + return samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)] +} +const rows = [] +for (const [shape, input] of [ + ['8KiB blank lines', '\n'.repeat(8192)], + ['16KiB blank lines', '\n'.repeat(16384)], + ['32KiB blank lines', '\n'.repeat(32768)], + ['32KiB blank lines then invalid key', `${'\n'.repeat(32768)}lowercase`], + ['ordinary env', 'FOO=value\nBAR=other\n'], + ['ordinary message', 'Cannot read directory /workspace/source: file not found'] +]) { + assert.equal(redactString(input), before(input)) + const beforeMs = median(before, input, 3) + const afterMs = median(redactString, input, 15) + rows.push({ + shape, + bytes: Buffer.byteLength(input), + beforeMs, + afterMs, + speedup: beforeMs / afterMs + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, rows }, null, 2)) diff --git a/config/scripts/relay-asset-line-ending-pin.test.mjs b/config/scripts/relay-asset-line-ending-pin.test.mjs new file mode 100644 index 00000000000..3384aa9b88a --- /dev/null +++ b/config/scripts/relay-asset-line-ending-pin.test.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { RELAY_ARTIFACTS } from '../../src/shared/relay-artifacts.ts' +import { describe, expect, it } from 'vitest' + +/** + * Guard the `.gitattributes` pin that keeps `config/relay-assets` on LF. + * + * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a + * pin a Windows runner checks these out as CRLF. build-relay.mjs copies them + * verbatim into the bundle and hashes them byte-for-byte into `.version`, which + * names the immutable remote relay directory -- so a Windows-built client and a + * mac/Linux-built one disagree on the same release, and one SSH host ends up with + * two relay trees, each paying its own remote native-dep compile. + * + * Measured on v1.4.197: master-cloexec-patch.cjs shipped at 11229 bytes from the + * mac runner and 11547 (= 11229 + 318 lines) from the Windows one. + */ +const projectDir = resolve(import.meta.dirname, '../..') + +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 +} + +/** + * Keyed off the manifest, not a directory: build-relay refuses to emit an + * artifact absent from RELAY_ARTIFACTS, so relocating an asset cannot slip + * past this the way a path glob would. esbuild bundles have no tracked + * source and contribute no hits, so they need no classifying. + */ +function trackedManifestSources() { + const paths = new Set() + for (const { filename } of RELAY_ARTIFACTS) { + const hits = git(['ls-files', '-z', '--', `*/${filename}`]) + .split('\0') + .filter(Boolean) + for (const path of hits) { + paths.add(path) + } + } + return [...paths] +} + +describe('config/relay-assets line-ending pin', () => { + it('pins every tracked relay artifact source to LF', () => { + const assets = trackedManifestSources() + expect(assets.length).toBeGreaterThan(0) + + const attributes = eolAttributes(assets) + const unpinned = assets.filter((path) => attributes.get(path) !== 'lf') + + expect( + unpinned, + 'A relay asset left on the platform default gets CRLF on a Windows runner, ' + + 'which changes the .version hash and splits one release across two remote ' + + 'relay directories. Pin it in .gitattributes.' + ).toEqual([]) + }) + + // Why: the assertion above only sees files that exist today. These fix the + // pattern itself -- broad enough to cover a file added tomorrow, narrow enough + // not to claim neighbours. + it.each([ + ['config/relay-assets/example.cjs', 'lf'], + ['config/relay-assets/nested/deeper/example.cjs', 'lf'], + ['config/relay-assets/example.txt', 'lf'], + ['config/relay-assets-extra/example.cjs', 'unspecified'], + ['vendor/config/relay-assets/example.cjs', 'unspecified'] + ])('resolves %s to eol=%s', (path, expected) => { + expect(eolAttributes([path]).get(path)).toBe(expected) + }) +}) diff --git a/config/scripts/relay-frame-buffer-benchmark.mjs b/config/scripts/relay-frame-buffer-benchmark.mjs new file mode 100644 index 00000000000..24d7b565400 --- /dev/null +++ b/config/scripts/relay-frame-buffer-benchmark.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' + +// Pass the pre-change source saved with git show :src/shared/relay-frame-buffer.ts. +const baselinePath = process.argv[2] +if (!baselinePath) { + throw new Error('Usage: node config/scripts/relay-frame-buffer-benchmark.mjs ') +} +async function load(source) { + return ( + await import( + `data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}` + ) + ).RelayFrameBuffer +} +const Before = await load(readFileSync(baselinePath, 'utf8')) +const After = await load( + readFileSync(new URL('../../src/shared/relay-frame-buffer.ts', import.meta.url), 'utf8') +) +function median(values) { + return values.sort((a, b) => a - b)[Math.floor(values.length / 2)] +} +for (const count of [1, 256, 16384, 65536]) { + const chunks = Array.from({ length: count }, (_, index) => Buffer.alloc(64, index % 256)) + const expected = Buffer.concat(chunks) + for (const mode of ['take', 'discard']) { + const times = [[], []] + for (let round = 0; round < 9; round += 1) { + for (const arm of round % 2 === 0 ? [0, 1] : [1, 0]) { + const FrameBuffer = arm === 0 ? Before : After + const buffer = new FrameBuffer() + for (const chunk of chunks) { + buffer.append(chunk) + } + const start = performance.now() + const output = buffer[mode](expected.length) + times[arm].push(performance.now() - start) + if (mode === 'take') { + assert.deepEqual(output, expected) + } + assert.equal(buffer.length, 0) + buffer.append(Buffer.from('tail')) + assert.equal(buffer.drain().toString(), 'tail') + } + } + const beforeMs = median(times[0]), + afterMs = median(times[1]) + console.log( + JSON.stringify({ + mode, + chunks: count, + bytes: expected.length, + beforeMs, + afterMs, + speedup: beforeMs / afterMs + }) + ) + } +} diff --git a/config/scripts/replace-cached-nsis-elevate.mjs b/config/scripts/replace-cached-nsis-elevate.mjs new file mode 100644 index 00000000000..fcd1a7323d4 --- /dev/null +++ b/config/scripts/replace-cached-nsis-elevate.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node + +// Why: electron-builder re-runs `CopyElevateHelper.copy` on every NSIS pack, so the +// release rebuild overwrites the SignPath-signed `resources/elevate.exe` with the +// unsigned copy sitting in the electron-builder toolset cache. The release workflow +// swapped the cached copy first, but searched `/nsis` — a directory no current +// app-builder-lib layout creates (real ones are `/nsis-3.0.4.1/nsis-3.0.4.1-/` +// and `/nsis@/nsis-bundle--/`), so the swap silently found +// nothing and v1.4.193/v1.4.194 shipped an unsigned UAC elevation helper. + +import { copyFileSync, readdirSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { homedir, platform as osPlatform, tmpdir } from 'node:os' +import { join, parse, resolve } from 'node:path' + +const require = createRequire(import.meta.url) + +const ELEVATE_EXE = 'elevate.exe' + +// `nsis` (the layout the old hardcoded path assumed), `nsis-3.0.4.1` (legacy bundle via +// `getBinFromUrl`), `nsis@1.2.1` (unified bundle). Not `customNsisBinary`: the +// `nsis-` key `getBinFromCustomLoc` builds is only `getBin`'s in-process promise +// key, and the extract dir is named for the custom URL's parent segment, which need not +// start with `nsis` at all. Only the app-builder-lib probe covers that layout — which is +// why the probe, not this scan, is what decides whether the swap succeeded. +const NSIS_RELEASE_DIR = /^nsis(?:[-@].*)?$/i + +// elevate.exe lives at the bundle root, one level under the release dir. The legacy +// bundle carries thousands of files under Contrib/, so an unbounded walk is both slow +// and a way to match something that is not a toolset copy. +const MAX_DEPTH = 3 + +function isFile(path) { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +/** + * Mirrors `getCacheDirectory` in app-builder-lib's `out/util/electronGet.js`, which is what + * decides where the NSIS bundle is unpacked. Kept as a local port rather than an import + * because the swap must still resolve a cache root when app-builder-lib cannot be loaded. + */ +export function resolveElectronBuilderCacheDir({ + env = process.env, + platform = osPlatform(), + home = homedir(), + temp = tmpdir() +} = {}) { + const override = env.ELECTRON_BUILDER_CACHE?.trim() + if (override && parse(override).root) { + return override + } + if (platform === 'darwin') { + return join(home, 'Library', 'Caches', 'electron-builder') + } + if (platform === 'win32') { + const localAppData = env.LOCALAPPDATA?.trim() + // https://github.com/electron-userland/electron-builder/issues/1164 + const isSystemUser = + localAppData?.toLowerCase().includes('\\windows\\system32\\') === true || + env.USERNAME?.trim().toLowerCase() === 'system' + if (!localAppData || isSystemUser) { + return join(temp, 'electron-builder-cache') + } + return join(localAppData, 'electron-builder', 'Cache') + } + const xdgCache = env.XDG_CACHE_HOME + return xdgCache && parse(xdgCache).root + ? join(xdgCache, 'electron-builder') + : join(home, '.cache', 'electron-builder') +} + +function collectElevateFiles(dir, depth, found) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isFile()) { + if (entry.name.toLowerCase() === ELEVATE_EXE) { + found.push(path) + } + } else if (entry.isDirectory() && depth > 1) { + collectElevateFiles(path, depth - 1, found) + } + } + return found +} + +/** + * Every cached `elevate.exe` under an NSIS release directory of `cacheDir`, plus the + * `ELECTRON_BUILDER_NSIS_DIR` override copy when that is set. + */ +export function findCachedElevatePaths(cacheDir, { env = process.env } = {}) { + const found = [] + const overrideDir = env.ELECTRON_BUILDER_NSIS_DIR?.trim() + if (overrideDir && isFile(join(overrideDir, ELEVATE_EXE))) { + found.push(join(overrideDir, ELEVATE_EXE)) + } + let entries + try { + entries = readdirSync(cacheDir, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + if (entry.isDirectory() && NSIS_RELEASE_DIR.test(entry.name)) { + collectElevateFiles(join(cacheDir, entry.name), MAX_DEPTH, found) + } + } + return found +} + +/** + * The exact path `CopyElevateHelper` will pack, asked of app-builder-lib itself. Returns the + * failure instead of logging it: an unavailable probe leaves the directory scan as the only + * signal, and the caller has to say that out loud rather than quietly passing. + */ +export async function resolveToolsetElevatePath(projectDir = process.cwd()) { + try { + const configPath = require.resolve(resolve(projectDir, 'config/electron-builder.config.cjs')) + const config = require(configPath) + const { getNsisElevatePath } = require('app-builder-lib/out/toolsets/windows.js') + const path = await getNsisElevatePath(config.toolsets?.nsis, config.nsis?.customNsisBinary) + return { path, error: null } + } catch (error) { + return { path: null, error: error.message } + } +} + +/** + * Replaces every cached copy rather than picking one. Which bundle the rebuild packs + * depends on the toolset version resolved at pack time, and each cached copy is an + * unsigned `elevate.exe` that a later pack could reach for; the helper is a standalone + * UAC shim, not coupled to the NSIS version around it, so overwriting all of them is safe. + * + * `toolsetReplaced` is the signal that matters. A non-empty `replaced` only says that some + * cached copy was rewritten, which a stale release directory carried in by the + * `electron-builder-win-` prefix restore can satisfy on its own. + */ +export async function replaceCachedElevateHelpers({ + signedPath, + cacheDir = resolveElectronBuilderCacheDir(), + projectDir = process.cwd(), + env = process.env, + probe = resolveToolsetElevatePath +} = {}) { + if (!isFile(signedPath)) { + throw new Error(`Signed elevate.exe not found: ${signedPath}`) + } + const targets = new Set(findCachedElevatePaths(cacheDir, { env })) + const { path: toolsetPath, error: toolsetError } = await probe(projectDir) + if (toolsetPath != null && isFile(toolsetPath)) { + targets.add(toolsetPath) + } + + const replaced = [] + for (const target of targets) { + copyFileSync(signedPath, target) + replaced.push(target) + } + return { + replaced, + cacheDir, + toolsetPath, + toolsetError, + toolsetReplaced: toolsetPath != null && replaced.includes(toolsetPath) + } +} + +/** + * The annotations and exit code a swap result earns. Split out so every branch is testable + * without a subprocess — including the one that made this defect class possible, where the + * step passes because *a* cached copy was replaced while the copy the rebuild packs was not. + */ +export function summarizeSwap({ replaced, cacheDir, toolsetPath, toolsetError, toolsetReplaced }) { + if (toolsetPath != null && !toolsetReplaced) { + return { + annotations: [ + { + level: 'error', + message: + `app-builder-lib resolves the elevate.exe the NSIS rebuild will pack to ${toolsetPath}, ` + + 'but that path could not be replaced, so the installer will ship an unsigned UAC ' + + 'elevation helper.' + } + ], + exitCode: 1 + } + } + if (replaced.length === 0) { + return { + annotations: [ + { + level: 'error', + message: + `No cached elevate.exe found under ${cacheDir}; the NSIS rebuild will pack the unsigned ` + + 'helper and ship an unsigned UAC elevation binary. The electron-builder toolset cache ' + + 'layout has changed — update config/scripts/replace-cached-nsis-elevate.mjs.' + } + ], + exitCode: 1 + } + } + if (toolsetPath == null) { + // A green step must never quietly mean "the authoritative check did not run". The scan + // alone is satisfiable by a stale release directory that the `electron-builder-win-` + // prefix restore carried across a lockfile change, while the bundle the rebuild actually + // packs sits in a directory this scan does not match. + return { + annotations: [ + { + level: 'warning', + message: + 'Could not ask app-builder-lib which elevate.exe the NSIS rebuild will pack ' + + `(${toolsetError}); replaced ${replaced.length} copies found by scanning ${cacheDir} ` + + 'alone, which a stale release directory can satisfy while the packed copy stays unsigned.' + } + ], + exitCode: 0 + } + } + return { annotations: [], exitCode: 0 } +} + +// Why an exit code and not a warning: a swap that misses the copy the rebuild packs exits +// before that rebuild restores the unsigned helper, so a silent success here is +// indistinguishable from a release that shipped a signed one — which is how this went +// unnoticed for two releases. The workflow step is `continue-on-error`, so this annotates +// loudly without making a release unbuildable. +if (import.meta.filename === process.argv[1]) { + const signedPath = process.argv[2] + if (!signedPath) { + process.stderr.write('Usage: replace-cached-nsis-elevate.mjs \n') + process.exit(2) + } + try { + const result = await replaceCachedElevateHelpers({ signedPath }) + const { annotations, exitCode } = summarizeSwap(result) + for (const { level, message } of annotations) { + process.stdout.write(`::${level}::${message}\n`) + } + if (exitCode === 0) { + for (const path of result.replaced) { + const role = path === result.toolsetPath ? ' (the copy app-builder-lib will pack)' : '' + process.stdout.write(`Replaced ${path} with the SignPath-signed copy.${role}\n`) + } + } + process.exit(exitCode) + } catch (error) { + process.stdout.write(`::error::Could not replace the cached elevate.exe: ${error.message}\n`) + process.exit(1) + } +} diff --git a/config/scripts/replace-cached-nsis-elevate.test.mjs b/config/scripts/replace-cached-nsis-elevate.test.mjs new file mode 100644 index 00000000000..a88461703c3 --- /dev/null +++ b/config/scripts/replace-cached-nsis-elevate.test.mjs @@ -0,0 +1,364 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +import { + findCachedElevatePaths, + replaceCachedElevateHelpers, + resolveElectronBuilderCacheDir, + summarizeSwap +} from './replace-cached-nsis-elevate.mjs' + +// The probe is app-builder-lib asking itself where the packed elevate.exe lives; injected +// here so no test needs the network or a warm toolset cache. +const probeFound = (path) => async () => ({ path, error: null }) +const probeUnavailable = async () => ({ path: null, error: 'app-builder-lib not loadable' }) + +const projectRoot = resolve(import.meta.dirname, '../..') +const scriptPath = join(projectRoot, 'config/scripts/replace-cached-nsis-elevate.mjs') + +let scratch + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), 'orca elevate swap ')) +}) + +afterEach(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function makeCache(...relativeFiles) { + const cacheDir = join(scratch, 'Cache') + for (const relative of relativeFiles) { + const path = join(cacheDir, ...relative.split('/')) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, 'unsigned-elevate') + } + mkdirSync(cacheDir, { recursive: true }) + return cacheDir +} + +describe('cached elevate.exe swap covers the real electron-builder layouts', () => { + // Why these exact shapes: `downloadBuilderToolset` unpacks to + // `//-/`, and `releaseName` is + // `nsis-3.0.4.1` on the legacy bundle (`getBinFromUrl`) and `nsis@` on the + // unified bundle. The release workflow searched `/nsis`, which matches none of + // them. `customNsisBinary` is deliberately absent — see the probe suite below. + it.each([ + ['legacy bundle', 'nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe'], + ['unified bundle', 'nsis@1.2.1/nsis-bundle-3.12-k4d9x/elevate.exe'], + ['bare nsis release dir', 'nsis/nsis-3.0.4.1/elevate.exe'] + ])('finds the cached helper in the %s layout', (_label, relative) => { + const cacheDir = makeCache(relative) + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([ + join(cacheDir, ...relative.split('/')) + ]) + }) + + it('leaves other toolsets and the raw download dir alone', () => { + const cacheDir = makeCache( + 'winCodeSign/winCodeSign-2.6.0-abc12/elevate.exe', + 'downloads/nsis/elevate.exe' + ) + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([]) + }) + + // `nsis-resources-3.4.1` matches the release-dir pattern and is scanned. Documented + // rather than excluded: `getLegacyNsisResourcesBin` ships plugins, never an elevate.exe, + // so the over-match costs one cheap directory read and nothing else. Narrowing the + // pattern to exclude it would be a guess about a name app-builder-lib owns. + it('scans the resources bundle too, which ships no helper to find', () => { + expect( + findCachedElevatePaths(makeCache('nsis-resources-3.4.1/plugins/x86-unicode/nsProcess.dll'), { + env: {} + }) + ).toEqual([]) + + const planted = 'nsis-resources-3.4.1/nsis-resources-3.4.1-p8w1z/elevate.exe' + const cacheDir = makeCache(planted) + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([ + join(cacheDir, ...planted.split('/')) + ]) + }) + + // The rebuild picks one bundle, and nothing outside app-builder-lib knows which. + // Replacing every cached copy is the deliberate answer to that ambiguity. + it('replaces every cached copy when several bundles are present', async () => { + const cacheDir = makeCache( + 'nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe', + 'nsis@1.2.1/nsis-bundle-3.12-k4d9x/elevate.exe' + ) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const { replaced } = await replaceCachedElevateHelpers({ + signedPath: signed, + cacheDir, + env: {}, + probe: probeUnavailable + }) + + expect(replaced).toHaveLength(2) + for (const path of replaced) { + expect(readFileSync(path, 'utf8')).toBe('signpath-signed-elevate') + } + }) + + it('covers the ELECTRON_BUILDER_NSIS_DIR override copy', () => { + const overrideDir = join(scratch, 'nsis-override') + mkdirSync(overrideDir, { recursive: true }) + writeFileSync(join(overrideDir, 'elevate.exe'), 'unsigned-elevate') + const cacheDir = makeCache() + + expect( + findCachedElevatePaths(cacheDir, { env: { ELECTRON_BUILDER_NSIS_DIR: overrideDir } }) + ).toEqual([join(overrideDir, 'elevate.exe')]) + }) + + it('resolves the cache root the same way app-builder-lib does', () => { + expect( + resolveElectronBuilderCacheDir({ + env: { LOCALAPPDATA: 'C:\\Users\\runneradmin\\AppData\\Local' }, + platform: 'win32' + }) + ).toBe(join('C:\\Users\\runneradmin\\AppData\\Local', 'electron-builder', 'Cache')) + expect(resolveElectronBuilderCacheDir({ env: {}, platform: 'darwin', home: '/Users/a' })).toBe( + join('/Users/a', 'Library', 'Caches', 'electron-builder') + ) + expect(resolveElectronBuilderCacheDir({ env: { ELECTRON_BUILDER_CACHE: '/mnt/cache' } })).toBe( + '/mnt/cache' + ) + }) + + // Proof against the layout actually on disk, not just the fixtures. Cross-checked + // against an independent unbounded walk so a search that scopes itself wrongly + // cannot pass by finding nothing — which is exactly how the inline path passed. + // Skipped only where no NSIS bundle has been downloaded into the cache yet. + it('finds every elevate.exe the real electron-builder cache holds', (ctx) => { + const cacheDir = resolveElectronBuilderCacheDir() + if (!existsSync(cacheDir)) { + // Reported as skipped, never as passed: this is the one test that checks the scan + // against a layout nobody wrote down, and a silent no-op here is the suite + // confirming itself. The Linux unit-test job has no electron-builder cache. + ctx.skip() + return + } + const walk = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return walk(path) + } + return entry.name.toLowerCase() === 'elevate.exe' ? [path] : [] + }) + const onDisk = walk(cacheDir) + if (onDisk.length === 0) { + ctx.skip() + return + } + expect(findCachedElevatePaths(cacheDir, { env: {} }).sort()).toEqual(onDisk.sort()) + }) +}) + +describe('the probe, not the scan, decides whether the swap worked', () => { + // Why the probe is load-bearing: `getBinFromCustomLoc` passes `nsis-` to `getBin` + // as its in-process promise key only — the extract dir is named for the custom URL's parent + // segment, so a customNsisBinary bundle can sit outside `nsis*` entirely. + it('covers a custom bundle the directory scan cannot match', async () => { + const relative = 'orca-nsis-mirror/nsis-custom-3.11-0zqp2/elevate.exe' + const cacheDir = makeCache(relative) + const packed = join(cacheDir, ...relative.split('/')) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([]) + + const result = await replaceCachedElevateHelpers({ + signedPath: signed, + cacheDir, + env: {}, + probe: probeFound(packed) + }) + + expect(result.toolsetReplaced).toBe(true) + expect(readFileSync(packed, 'utf8')).toBe('signpath-signed-elevate') + expect(summarizeSwap(result)).toEqual({ annotations: [], exitCode: 0 }) + }) + + // The shape that reproduced the hole: release-cut.yml restores the toolset cache with + // `restore-keys: electron-builder-win-`, so a stale release directory survives a lockfile + // change. Replacing that stale copy satisfies `replaced.length > 0` on its own while the + // bundle the rebuild packs sits in a directory the scan never matches. + it('does not call a stale directory a success when the packed bundle is unmatched', async () => { + const stale = 'nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe' + const packed = 'builder-nsis@4.0.0/nsis-bundle-4.0-k4d9x/elevate.exe' + const cacheDir = makeCache(stale, packed) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = await replaceCachedElevateHelpers({ + signedPath: signed, + cacheDir, + env: {}, + probe: probeUnavailable + }) + + // The scan rewrote only the stale copy; the one that would be packed is untouched. + expect(result.replaced).toEqual([join(cacheDir, ...stale.split('/'))]) + expect(readFileSync(join(cacheDir, ...packed.split('/')), 'utf8')).toBe('unsigned-elevate') + + // So the run must not look clean. + const { annotations, exitCode } = summarizeSwap(result) + expect(exitCode).toBe(0) + expect(annotations).toHaveLength(1) + expect(annotations[0].level).toBe('warning') + expect(annotations[0].message).toContain('Could not ask app-builder-lib') + }) + + it('fails when the probe names a copy that could not be replaced', () => { + const summary = summarizeSwap({ + replaced: ['C:/cache/nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe'], + cacheDir: 'C:/cache', + toolsetPath: 'C:/cache/nsis@2.0.0/nsis-bundle-4.0-k4d9x/elevate.exe', + toolsetError: null, + toolsetReplaced: false + }) + + expect(summary.exitCode).toBe(1) + expect(summary.annotations[0].level).toBe('error') + expect(summary.annotations[0].message).toContain('will pack') + }) + + it('fails when nothing at all was replaced', () => { + const summary = summarizeSwap({ + replaced: [], + cacheDir: 'C:/cache', + toolsetPath: null, + toolsetError: 'app-builder-lib not loadable', + toolsetReplaced: false + }) + + expect(summary.exitCode).toBe(1) + expect(summary.annotations[0].level).toBe('error') + expect(summary.annotations[0].message).toContain('No cached elevate.exe found') + }) +}) + +describe('a cached elevate.exe miss is not silent', () => { + // ELECTRON_BUILDER_NSIS_DIR short-circuits app-builder-lib's own resolution before + // any download, so the probe fails offline instead of fetching the NSIS bundle. + function runScript(cacheDir, nsisDir, signedPath) { + return spawnSync(process.execPath, [scriptPath, signedPath], { + cwd: projectRoot, + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_BUILDER_CACHE: cacheDir, + ELECTRON_BUILDER_NSIS_DIR: nsisDir + } + }) + } + + it('exits non-zero with an ::error:: annotation when no cached copy is found', () => { + const cacheDir = makeCache() + const emptyNsisDir = join(scratch, 'empty-nsis') + mkdirSync(emptyNsisDir, { recursive: true }) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = runScript(cacheDir, emptyNsisDir, signed) + + expect(result.status).toBe(1) + expect(result.stdout).toContain('::error::No cached elevate.exe found') + }) + + it('warns on the scan-only path so green never means the probe was skipped', () => { + const cacheDir = makeCache('nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe') + const emptyNsisDir = join(scratch, 'empty-nsis') + mkdirSync(emptyNsisDir, { recursive: true }) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = runScript(cacheDir, emptyNsisDir, signed) + + expect(result.status).toBe(0) + expect(result.stdout).not.toContain('::error::') + expect(result.stdout).toContain('::warning::Could not ask app-builder-lib') + expect( + readFileSync(join(cacheDir, 'nsis-3.0.4.1', 'nsis-3.0.4.1-1mx3n', 'elevate.exe'), 'utf8') + ).toBe('signpath-signed-elevate') + }) + + // The healthy release-job path: app-builder-lib answers, so the copy it will pack is the + // one that gets replaced and there is nothing to warn about. + it('exits clean when the probe resolves the copy the rebuild will pack', () => { + const cacheDir = makeCache() + const nsisDir = join(scratch, 'nsis-bundle') + mkdirSync(nsisDir, { recursive: true }) + writeFileSync(join(nsisDir, 'elevate.exe'), 'unsigned-elevate') + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = runScript(cacheDir, nsisDir, signed) + + expect(result.status).toBe(0) + expect(result.stdout).not.toContain('::error::') + expect(result.stdout).not.toContain('::warning::') + expect(result.stdout).toContain('the copy app-builder-lib will pack') + expect(readFileSync(join(nsisDir, 'elevate.exe'), 'utf8')).toBe('signpath-signed-elevate') + }) +}) + +describe('release-cut.yml swaps the cached elevate.exe through the resolver', () => { + function swapStep() { + const workflow = parse( + readFileSync(join(projectRoot, '.github/workflows/release-cut.yml'), 'utf8') + ) + const step = workflow.jobs.build.steps.find( + (candidate) => candidate.name === 'Replace cached elevate.exe with the signed copy' + ) + expect(step).toBeDefined() + return step + } + + it('delegates the cache lookup to the script instead of an inline path', () => { + const step = swapStep() + expect(step.run).toContain('node config/scripts/replace-cached-nsis-elevate.mjs $signed') + // The hardcoded miss that shipped v1.4.193/v1.4.194 unsigned. + expect(step.run).not.toContain('electron-builder\\Cache\\nsis') + expect(step.run).not.toContain('-ErrorAction SilentlyContinue') + }) + + it('fails the step when the swap reports a miss', () => { + const step = swapStep() + // Matched as an executed statement: downgrading this to a Write-Host restores + // the silent fail-open that let the unsigned helper ship. + expect(step.run).toMatch(/if \(\$LASTEXITCODE -ne 0\) \{/) + expect(step.run).toMatch(/^\s*throw \$message\s*$/m) + expect(step.run).toContain('GITHUB_STEP_SUMMARY') + }) + + // Why kept: windows-signing-rehearsal.yml shares the electron-builder-win- + // cache key, so dropping this guard would let a test certificate reach a release cache. + it('still refuses to stage anything but a SignPath-signed helper', () => { + const step = swapStep() + expect(step.run).toContain("$signature.Status -ne 'Valid'") + expect(step.run).toContain("$subject -notlike '*CN=SignPath Foundation*'") + }) + + // The inner-signing chain stays fail-open: a loud red step, not an unbuildable release. + it('keeps the step unable to fail the release job', () => { + expect(swapStep()['continue-on-error']).toBe(true) + }) +}) diff --git a/config/scripts/repo-icon-source-href-benchmark.mjs b/config/scripts/repo-icon-source-href-benchmark.mjs new file mode 100644 index 00000000000..76c42d261b4 --- /dev/null +++ b/config/scripts/repo-icon-source-href-benchmark.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import { extractIconHref } from '../../src/main/repo-icon-source-href.ts' + +// Original production expressions, preserved for the before/after measurement. +const html = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const object = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i +const original = (source) => source.match(html)?.[1] ?? source.match(object)?.[1] ?? null + +function measurePair(source) { + original(source) + extractIconHref(source) + const beforeSamples = [] + const afterSamples = [] + for (let run = 0; run < 5; run++) { + const measurements = [ + [original, beforeSamples], + [extractIconHref, afterSamples] + ] + if (run % 2 === 1) { + measurements.reverse() + } + for (const [fn, samples] of measurements) { + const started = performance.now() + fn(source) + samples.push(performance.now() - started) + } + } + return { + beforeMs: beforeSamples.sort((a, b) => a - b)[2], + afterMs: afterSamples.sort((a, b) => a - b)[2] + } +} + +const results = [] +for (const size of [8192, 16384, 32768]) { + for (const shape of ['no icon', 'rel without href', 'unterminated link starts']) { + const source = + shape === 'unterminated link starts' + ? '') +} +const source = execFileSync('git', ['show', `${ref}:src/shared/source-scan/source-tree-scan.ts`], { + encoding: 'utf8' +}) +const { blankStringContents: before } = await import( + `data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}` +) +const tokens = [ + 'a', + '/', + '*', + ' ', + '\n', + '\r', + '\t', + '\u00a0', + '\u2028', + '"', + "'", + '`', + '${', + '}', + '{', + '\\', + '(', + ')', + '[', + ']', + '=', + '+', + '-', + ';' +] +let seed = 173 +for (let sample = 0; sample < 3000; sample++) { + let input = '' + for (let token = 0; token < 40; token++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + input += tokens[seed % tokens.length] + } + assert.equal(after(input), before(input), JSON.stringify(input)) + assert.equal(after(input, true), before(input, true), JSON.stringify(input)) +} +function measure(fn, input) { + const samples = [] + for (let run = 0; run < 3; run++) { + const start = performance.now() + fn(input) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[1] +} +const results = [] +for (const lines of [100, 1000, 5000, 10000]) { + const input = 'const x = value / 2;\n'.repeat(lines) + assert.equal(after(input), before(input)) + results.push({ + lines, + bytes: Buffer.byteLength(input), + beforeMs: measure(before, input), + afterMs: measure(after, input) + }) +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases: 3000, results }, + null, + 2 + ) +) diff --git a/config/scripts/terminal-ime-engagement-receipt.mjs b/config/scripts/terminal-ime-engagement-receipt.mjs index 9ad5255d235..8f0732908c1 100644 --- a/config/scripts/terminal-ime-engagement-receipt.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.mjs @@ -13,7 +13,8 @@ export const IME_ENGAGEMENT_RECEIPT_ENV = 'ORCA_E2E_IME_ENGAGEMENT_RECEIPT' /** The tests that must each leave a receipt. Pinned so deleting one cannot quietly shrink the lane. */ export const EXPECTED_NATIVE_IME_TESTS = [ 'forwards the issue exact-byte sequence without loss or duplication', - 'forwards the issue sentence stress sequence without leaked ASCII' + 'forwards the issue sentence stress sequence without leaked ASCII', + 'a digit typed right after a Hangul syllable reaches the pty' ] function parseReceipts(text) { diff --git a/config/scripts/terminal-ime-engagement-receipt.test.mjs b/config/scripts/terminal-ime-engagement-receipt.test.mjs index 04161339a0f..613abc2ffae 100644 --- a/config/scripts/terminal-ime-engagement-receipt.test.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.test.mjs @@ -4,7 +4,7 @@ import { verifyImeEngagementReceipts } from './terminal-ime-engagement-receipt.mjs' -const [firstTest, secondTest] = EXPECTED_NATIVE_IME_TESTS +const [firstTest, secondTest, thirdTest] = EXPECTED_NATIVE_IME_TESTS function receipt(test, overrides = {}) { return JSON.stringify({ @@ -18,9 +18,11 @@ function receipt(test, overrides = {}) { describe('verifyImeEngagementReceipts', () => { it('accepts a run where every expected test observed real composition', () => { - expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual( - [] - ) + expect( + verifyImeEngagementReceipts( + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + ) + ).toEqual([]) }) // The failure this whole mechanism exists for: Playwright reports a skipped test as a pass, so @@ -35,13 +37,20 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a partial run where only one test reached the engine', () => { expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n`)).toEqual([ - `no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed` + `no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed`, + `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` + ]) + }) + + it('requires the digit receipt even when both original native tests passed', () => { + expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual([ + `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` ]) }) it('rejects a run that typed keys but never opened a composition', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n` + `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no compositionstart — the IME never engaged` @@ -50,7 +59,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a composition that produced no Hangul, which a latin passthrough would satisfy', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n` + `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no Hangul composition data — the engine produced no syllables` @@ -59,7 +68,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a renamed test rather than counting it toward coverage', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt('some new scenario')}\n` + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt('some new scenario')}\n` ) expect(problems).toEqual([ 'unexpected engagement receipt for "some new scenario" — update EXPECTED_NATIVE_IME_TESTS' @@ -68,7 +77,7 @@ describe('verifyImeEngagementReceipts', () => { it('reports a truncated receipt rather than parsing around it', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n` + `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` ) expect(problems).toEqual(['malformed receipt line: {"test":"trunc']) }) diff --git a/config/scripts/verify-dev-channel-packaging.test.mjs b/config/scripts/verify-dev-channel-packaging.test.mjs index 63e1c7d5b0c..8e5a00f48e1 100644 --- a/config/scripts/verify-dev-channel-packaging.test.mjs +++ b/config/scripts/verify-dev-channel-packaging.test.mjs @@ -53,6 +53,19 @@ describe('electron-builder dev-channel identity', () => { expect(config.win.verifyUpdateCodeSignature).toBe(false) }) + // Why on every channel: the hook is the only handle electron-builder gives on + // the NSIS uninstaller, and it signs nothing — it relays the file to and from + // the CI SignPath request. Carrying it must not drag a publisherName onto a + // dev build, which is the failure the split above exists to prevent. + it('carries the uninstaller sign hook without changing publisherName semantics', () => { + for (const env of [{}, WIN_ADHOC_ENV]) { + const config = loadConfigWithEnv(env) + expect(typeof config.win.signtoolOptions.sign).toBe('function') + } + expect(loadConfigWithEnv({}).win.signtoolOptions.publisherName).toBe('SignPath Foundation') + expect(loadConfigWithEnv(WIN_ADHOC_ENV).win.signtoolOptions.publisherName).toBeUndefined() + }) + it.each([ ['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'], ['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'], diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index c815407d6d0..20d91e55497 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -9,7 +9,8 @@ * hop escapes the store and configure fails with "node_addon_api.gyp not * found" (run 32999886072). */ -import { copyFileSync, mkdirSync, realpathSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' @@ -22,6 +23,16 @@ export const WINDOWS_PROCESS_TREE_PACKAGE_DIR = join( 'windows-process-tree' ) +export const WINDOWS_PROCESS_TREE_PATCH_PATH = join( + ROOT, + 'config', + 'patches', + '@vscode__windows-process-tree@0.8.0.patch' +) + +/** Only the patched reader defines this; the upstream one walks the PEB. */ +const COMMAND_LINE_PATCH_MARKER = 'kProcessCommandLineInformation' + export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [ 'napi.h', 'napi-inl.h', @@ -39,6 +50,119 @@ export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE } } +/** The binary the addon actually loads. */ +export function windowsProcessTreeAddonPath(packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { + return join(packageDir, 'build', 'Release', 'windows_process_tree.node') +} + +/** The import whose absence tells the patched binary from the published prebuilt. */ +const FLAGGED_IMPORT = 'ReadProcessMemory' + +/** + * Does this compiled addon still carry the flagged primitive? + * + * The patched reader never calls `ReadProcessMemory`, so the symbol is absent + * from its import table; the upstream build imports it. That makes this a + * property of the binary rather than of the source next to it, which matters + * because the published tarball ships a *loadable* prebuilt built from + * unpatched source: it is node-addon-api, so it satisfies a bare `require()` + * under both Node and Electron, and a skipped rebuild would use it. + * + * Tri-state, not a predicate: a binary that is not there has not been cleared, + * and a boolean makes "absent" indistinguishable from "verified clean" at every + * call site. Takes the binary path so the relay's staged addon -- which sits + * beside the bundle, with no package around it -- gets the same check. + * + * @param {string} addonPath + * @returns {'clean' | 'unpatched' | 'missing'} + */ +export function inspectWindowsProcessTreeAddon(addonPath) { + if (!existsSync(addonPath)) { + return 'missing' + } + return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean' +} + +/** + * Refuse to compile or load the upstream command-line reader. + * + * Unpatched, it opens every process with `PROCESS_VM_READ` and walks the PEB to + * recover the command line -- the primitive MDE scores as credential dumping, + * and the reason this package is patched at all. pnpm has been seen + * materializing this CRLF package with its patch missing, so repair the source + * from the patch file, and drop any binary that predates the repair. + */ +export function ensureWindowsProcessTreeCommandLinePatch( + packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR +) { + const source = join(packageDir, 'src', 'process_commandline.cc') + if (!existsSync(source)) { + throw new Error( + `${source} is missing, so the command-line patch cannot be verified. Run pnpm install.` + ) + } + let repaired = false + + if (!readFileSync(source, 'utf8').includes(COMMAND_LINE_PATCH_MARKER)) { + try { + execFileSync( + 'git', + [ + // Why force the line-ending mode: the patch is stored LF (a contract + // test forbids CR bytes in it), but upstream ships this source CRLF, + // so its pre-image lines and the file's differ by a CR. Under + // `core.autocrlf=false` -- Git's own built-in default, and what + // "checkout as-is" selects in the Git for Windows installer -- git + // compares them literally, the hunk does not match, and the repair + // throws. `input` normalizes line endings for that comparison and + // nothing else, so a hunk whose real content drifted is still + // rejected. Measured: without it, apply exits 1 at autocrlf=false and + // 0 at true/input; with it, 0 for CRLF and LF sources under all three. + '-c', + 'core.autocrlf=input', + 'apply', + '--include=src/process_commandline.cc', + WINDOWS_PROCESS_TREE_PATCH_PATH + ], + { + cwd: realpathSync(packageDir), + stdio: 'pipe', + // Why blind git to the repo: run inside a work tree, `git apply` + // prefixes patch paths with the cwd-relative prefix, silently skips + // everything that does not match -- and still exits 0. The package + // dir is always under the project root, so without this the repair + // reports success and changes nothing. + env: { ...process.env, GIT_DIR: join(packageDir, '.orca-no-such-git-dir') } + } + ) + } catch (error) { + throw new Error( + 'src/process_commandline.cc still reads the PEB, and repairing it from ' + + `${WINDOWS_PROCESS_TREE_PATCH_PATH} failed: ${error?.message ?? error}. Run pnpm install.` + ) + } + if (!readFileSync(source, 'utf8').includes(COMMAND_LINE_PATCH_MARKER)) { + throw new Error( + 'src/process_commandline.cc still reads the PEB after repair, so the patch did not ' + + 'apply. Run pnpm install.' + ) + } + repaired = true + } + + // A binary from before the repair -- or the tarball's own prebuilt -- would + // otherwise survive a skipped rebuild and load the flagged reader anyway. + // Deleting it can fail EPERM against a loaded (memory-mapped) addon, which + // `force: true` does not cover -- it only swallows ENOENT. That throw is the + // caller's to classify as a Windows file lock, so it must not be swallowed. + if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath(packageDir)) === 'unpatched') { + rmSync(windowsProcessTreeAddonPath(packageDir), { force: true }) + repaired = true + } + + return repaired +} + // Patched binding.gyp includes deps/node-addon-api; the tarball does not ship those headers. export function stageWindowsProcessTreeNodeAddonApiHeaders( packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index f4820e9430a..f2939b71179 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -10,8 +10,9 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, @@ -59,3 +60,40 @@ describe('windows-process-tree node-gyp rebuild', () => { } }) }) + +describe('inspecting a compiled windows-process-tree addon', () => { + let dir + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-addon-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('reports a binary that still imports ReadProcessMemory as unpatched', () => { + const addonPath = join(dir, 'windows_process_tree.node') + writeFileSync(addonPath, Buffer.from('MZ\0\0KERNEL32.dll\0ReadProcessMemory\0', 'binary')) + expect(inspectWindowsProcessTreeAddon(addonPath)).toBe('unpatched') + }) + + it('reports a binary without the import as clean', () => { + const addonPath = join(dir, 'windows_process_tree.node') + writeFileSync(addonPath, Buffer.from('MZ\0\0ntdll.dll\0NtQueryInformationProcess\0', 'binary')) + expect(inspectWindowsProcessTreeAddon(addonPath)).toBe('clean') + }) + + // The whole point of the tri-state: absence is not evidence of safety, and a + // boolean made "there is no binary" indistinguishable from "checked, clean". + it('reports an absent binary as missing rather than clean', () => { + expect(inspectWindowsProcessTreeAddon(join(dir, 'windows_process_tree.node'))).toBe('missing') + }) + + it('inspects whatever path it is handed, including a relay-staged addon', () => { + // The relay loads `./windows-process-tree.node` beside its bundle, which is + // nowhere near a node_modules package directory. + const staged = join(dir, 'windows-process-tree.node') + writeFileSync(staged, Buffer.from('MZ\0\0ReadProcessMemory\0', 'binary')) + expect(inspectWindowsProcessTreeAddon(staged)).toBe('unpatched') + }) +}) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs index 37edc2196d4..c321db8cfd2 100644 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -212,6 +213,7 @@ describe('Windows signing workflow contract', () => { 'Notify Slack that inner-binary signing is waiting for approval', 'Download signed inner binaries from SignPath', 'Restore signed inner binaries into unpacked app', + 'Restore signed uninstaller for the installer rebuild', 'Replace cached elevate.exe with the signed copy', 'Rebuild NSIS installer from signed unpacked app' ] @@ -222,3 +224,235 @@ describe('Windows signing workflow contract', () => { } }) }) + +// Why these exist: the NSIS uninstaller is generated inside electron-builder's +// uninstaller pass and deleted immediately after being embedded, so the only way +// CI can sign it is the export/import relay through win.signtoolOptions.sign. +// Every link is asserted here the way Orca.exe and conpty_console_list.node are. +describe('Windows NSIS uninstaller signing', () => { + const releaseSteps = () => readWorkflow('.github/workflows/release-cut.yml').jobs.build.steps + const stepNamed = (steps, name) => steps.find((step) => step.name === name) + + const EXPORT_ENV = 'ORCA_WIN_UNINSTALLER_EXPORT_PATH' + const SIGNED_ENV = 'ORCA_WIN_UNINSTALLER_SIGNED_PATH' + + it('exports the uninstaller from the first Windows build', () => { + const build = stepNamed(releaseSteps(), 'Build Windows release artifacts') + + expect(build.env[EXPORT_ENV]).toContain('uninstaller-signing') + expect(build.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') + }) + + // Why this is a test and not a comment: `files` in the electron-builder config + // is all-negation, so app-builder packs whatever is left in the checkout root. + // These steps retry, and a retried attempt would pack an unsigned .exe into + // app.asar — the very defect this chain removes. Every relay path must live + // outside the checkout. + it('keeps every relay path out of the packed checkout', () => { + const relayEnvValues = [ + ...releaseSteps(), + ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps + ].flatMap((step) => [step.env?.[EXPORT_ENV], step.env?.[SIGNED_ENV]].filter(Boolean)) + + expect(relayEnvValues.length).toBe(4) + for (const value of relayEnvValues) { + expect(value).toContain('runner.temp') + expect(value).not.toContain('github.workspace') + } + + const relayScripts = [ + ...releaseSteps(), + ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps + ] + .map((step) => step.run ?? '') + .filter((run) => run.includes('uninstaller-signing')) + + expect(relayScripts.length).toBeGreaterThan(0) + for (const run of relayScripts) { + // Why count occurrences rather than assert `toContain` once: a step + // carrying two relay paths could root the first in RUNNER_TEMP and leave + // the second bare-relative — which resolves against the checkout, and is + // exactly the shape of the defect this test exists to catch. + const mentions = run.match(/uninstaller-signing/g) ?? [] + const rooted = run.match(/Join-Path \$env:RUNNER_TEMP 'uninstaller-signing/g) ?? [] + + expect(rooted.length, run).toBe(mentions.length) + expect(run).not.toContain('$env:GITHUB_WORKSPACE') + } + }) + + it('stages the uninstaller into the same request as the inner binaries', () => { + const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') + + expect(stage.run).toContain('uninstaller-signing\\unsigned\\orca-uninstaller.exe') + expect(stage.run).toContain('uninstaller\\orca-uninstaller.exe') + // No third SignPath request: exactly two submissions, as budgeted for the + // 1h + 4h approval waits inside the 360-minute job cap. + const submissions = releaseSteps().filter( + (step) => step.uses === 'signpath/github-action-submit-signing-request@v2' + ) + expect(submissions).toHaveLength(2) + }) + + // A staged-but-unreturned uninstaller must not fail the inner chain, or a + // SignPath artifact-configuration gap would cost the inner-binary signatures. + it('keeps the uninstaller out of the inner-binary copy-back list', () => { + const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') + const restoreInner = stepNamed( + releaseSteps(), + 'Restore signed inner binaries into unpacked app' + ) + + expect(stage.run).not.toMatch(/\$list\.Add\(['"]uninstaller/) + expect(restoreInner.run).not.toContain('orca-uninstaller.exe') + }) + + // This step's outcome gates the upload of every inner binary, so a filesystem + // error while staging the uninstaller must not escape — otherwise one + // uninstaller-specific failure costs every inner-binary signature, which is + // strictly worse than the behaviour before this chain existed. + it('cannot let an uninstaller staging failure cost the inner-binary signatures', () => { + const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') + const uninstallerBlock = stage.run.slice(stage.run.indexOf('$exportedUninstaller')) + + expect(stage.run).toMatch(/try \{[\s\S]*\$exportedUninstaller[\s\S]*\} catch \{/) + expect(uninstallerBlock).toContain('::warning::Could not stage the NSIS uninstaller') + expect(uninstallerBlock).not.toContain('throw') + // Explicit, so the catch does not silently depend on GitHub's + // $ErrorActionPreference='Stop' default for `shell: pwsh`. + expect(uninstallerBlock).toContain('New-Item -ItemType Directory -Force -Path (Split-Path') + expect(uninstallerBlock).toMatch(/New-Item[^\r\n]*-ErrorAction Stop/) + expect(uninstallerBlock).toMatch(/Copy-Item[^\r\n]*-ErrorAction Stop/) + // The upload it gates still keys off this step, so the catch is load-bearing. + expect(stepNamed(releaseSteps(), 'Upload unsigned inner binaries for SignPath').if).toContain( + "steps.stage-inner.outcome == 'success'" + ) + }) + + it('re-injects the signed uninstaller into the rebuilt installer', () => { + const steps = releaseSteps() + const restore = stepNamed(steps, 'Restore signed uninstaller for the installer rebuild') + const rebuild = stepNamed(steps, 'Rebuild NSIS installer from signed unpacked app') + const names = steps.map((step) => step.name) + + expect(restore.if).toContain('github.run_attempt == 1') + expect(restore.if).toContain("steps.restore-signed-inner.outcome == 'success'") + expect(restore.run).toContain('orca-uninstaller.exe') + expect(names.indexOf(restore.name)).toBeLessThan(names.indexOf(rebuild.name)) + expect(rebuild.env[SIGNED_ENV]).toContain('uninstaller-signing') + // The rebuild must not depend on the uninstaller leg: a missing signed + // uninstaller ships today's installer, it does not skip the rebuild. + expect(rebuild.if).not.toContain('restore-signed-uninstaller') + }) + + // NSIS hides the uninstaller in a compressed data section the bundled 7za + // cannot read, so the gate proves it from the sign hook's digest receipt + // instead of extracting it — and only when the relay actually ran. + it('reports the embedded uninstaller in the inner-binary evidence gate', () => { + const gate = stepNamed(releaseSteps(), 'Verify Windows inner binary signatures') + + expect(gate.env.UNINSTALLER_SIGNING_COMPLETED).toBe( + "${{ steps.restore-signed-uninstaller.outcome == 'success' }}" + ) + expect(gate.run).toContain('.embedded-sha256') + expect(gate.run).toContain("$env:UNINSTALLER_SIGNING_COMPLETED -eq 'true'") + expect(gate.run).toContain('not signed by SignPath Foundation: Uninstall Orca.exe') + // The uninstaller must not join the 7z payload loop, which cannot see it. + expect(gate.run).not.toContain("$targets += 'Uninstall Orca.exe'") + }) + + it('rehearses the uninstaller leg end to end', () => { + const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse + .steps + const names = steps.map((step) => step.name) + const pack = stepNamed(steps, 'Package Windows app and export the NSIS uninstaller') + const rebuild = stepNamed(steps, 'Build NSIS installer from signed unpacked app') + const verify = stepNamed(steps, 'Verify signatures end to end') + + // --dir never produces an uninstaller, so the rehearsal has to build the + // installer the way release-cut's first Windows pass does. + expect(pack.run).toContain('--win --publish never') + expect(pack.run).not.toContain('--dir') + expect(pack.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') + expect(names).toContain('Restore signed uninstaller for the installer rebuild') + expect(rebuild.env[SIGNED_ENV]).toContain('orca-uninstaller.exe') + expect(verify.run).toContain('.embedded-sha256') + // The receipt only proves the import leg ran. The rehearsal is where the + // shipped uninstaller itself gets checked — the release job cannot install + // onto the runner it publishes from. + expect(verify.run).toContain('shipped: Uninstall Orca.exe') + expect(verify.run).toContain('-tnsis') + expect(verify.run).toContain("-ArgumentList '/S'") + }) + + // This workflow is the merge gate, so it must not be able to fail on its own + // artefact: 7-Zip's NSIS handler is unreliable enough that its output has to + // be corroborated before a signature verdict is drawn from it. + it('never lets an unreliable extract fail the rehearsal', () => { + const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse + .steps + const verify = stepNamed(steps, 'Verify signatures end to end') + + // The 7-Zip route is only trusted when it reproduces the relayed bytes; + // otherwise it falls through to the install route rather than failing. + expect(verify.run).toContain( + 'Write-Host "7-Zip\'s NSIS output did not match the relayed digest; falling back to a silent install."' + ) + expect(verify.run).toMatch(/\$installedUninstaller = \$null\r?\n\s*\}/) + + // The comparison that is not tautological: a file NSIS wrote out, against + // the digest the sign hook recorded. + expect(verify.run).toContain('$shippedDigest -ne $expectedDigest') + expect(verify.run).toContain('the uninstaller the installer ships is not the relayed one') + + // An installer that prompts must not hang to the 360-minute job cap, and + // the app it launches must not outlive the step holding install-dir handles. + expect(verify.run).toContain('-PassThru') + expect(verify.run).toContain('$installerProcess.WaitForExit(300000)') + expect(verify.run).toContain('the silent install did not exit within 5 minutes') + expect(verify.run).toMatch(/for \(\$attempt = 0; \$attempt -lt 20; \$attempt\+\+\)/) + expect(verify.run).toContain("Get-Process -Name 'orca-terminal-daemon'") + }) + + // resources\elevate.exe is downgraded to advisory because app-builder-lib's + // CopyElevateHelper clobbers it on every nsis pack — a pre-existing defect + // that predates the uninstaller relay and is being tracked separately. The + // escape hatch it needed is the kind that quietly grows until the gate + // asserts nothing, so pin it to exactly that one file. + it('confines the advisory escape hatch to elevate.exe', () => { + const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse + .steps + const verify = stepNamed(steps, 'Verify signatures end to end') + const advisoryCalls = verify.run + .split('\n') + .filter((line) => line.includes('-Advisory') && line.includes('Test-Signature')) + + expect(advisoryCalls).toHaveLength(1) + expect(advisoryCalls[0]).toContain('installed: $relative') + expect(verify.run).toContain("if ($relative -eq 'resources\\elevate.exe')") + + // Both uninstaller verdicts stay fatal — the whole point of the gate. + for (const call of ['relayed: orca-uninstaller.exe', 'shipped: Uninstall Orca.exe']) { + const line = verify.run + .split('\n') + .find((it) => it.includes(`Test-Signature`) && it.includes(call)) + expect(line, call).toBeDefined() + expect(line, call).not.toContain('-Advisory') + } + + // An advisory must still reach the evidence artifact, or downgrading it + // becomes indistinguishable from deleting the check. + expect(verify.run).toContain('ADVISORY (known pre-existing') + expect(verify.run).toContain('$script:advisories.Add($problem)') + }) + + it('wires the electron-builder sign hook that the relay depends on', () => { + const require = createRequire(import.meta.url) + const configPath = resolve(projectDir, 'config/electron-builder.config.cjs') + delete require.cache[require.resolve(configPath)] + const config = require(configPath) + + expect(typeof config.win.signtoolOptions.sign).toBe('function') + delete require.cache[require.resolve(configPath)] + }) +}) diff --git a/config/scripts/windows-uninstaller-signing.cjs b/config/scripts/windows-uninstaller-signing.cjs new file mode 100644 index 00000000000..c3243b4581a --- /dev/null +++ b/config/scripts/windows-uninstaller-signing.cjs @@ -0,0 +1,111 @@ +// Why this exists: the NSIS uninstaller is the one Orca binary SignPath never +// saw. app-builder-lib builds it in a separate makensis pass, hands it to the +// packager's sign hook, embeds it in the installer, then deletes it +// (NsisTarget.computeScriptAndSignUninstaller → packager.signIf(uninstallerPath), +// then `unlink(defines.UNINSTALLER_OUT_FILE)`). That hook is the only moment the +// file exists on disk, so it is the only place a post-hoc signer can reach it. +// +// Orca does not sign during electron-builder — SignPath signs afterwards, behind +// a human approval — so instead of signing, this hook relays: build 1 exports the +// unsigned uninstaller so CI can put it in the existing inner-binaries SignPath +// request, and the rebuild-from-signed-tree pass swaps the signed bytes back in +// before makensis embeds them. +// +// Trap for whoever adds a real certificate to the Windows build: a custom sign +// hook *replaces* signtool rather than running alongside it — windowsSignToolManager +// does `const executor = customSign || (config => this.doSign(config))`. Inert +// today (no CSC_LINK/WIN_CSC_LINK anywhere in the Windows workflows), but setting +// one would silently sign nothing until this hook learns to delegate. +// +// Trap for whoever adds a second NSIS target or arch: app-builder-lib names the +// intermediate uninstaller per target *and* arch, while the relay is a single +// pair of env vars. Two targets would race — last write wins on export, every +// installer would embed the same uninstaller, and the receipt could not tell. +// Release is x64-only `--win` with `win.target` unset (so `["nsis"]`) today. +const { createHash } = require('node:crypto') +const { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs') +const { basename, dirname } = require('node:path') + +// app-builder-lib names the intermediate uninstaller `__uninstaller.exe`. +const UNINSTALLER_BASENAME_SUFFIX = '__uninstaller.exe' + +// Why a receipt: NSIS embeds the uninstaller in its own compressed data section, +// not in the app 7z payload the evidence gate extracts, so the shipped installer +// cannot be inspected for it with the bundled 7za. The receipt records the digest +// of the exact bytes handed to makensis, which the gate compares against the +// SignPath-returned file — proving what was embedded without extracting it. +const EMBEDDED_RECEIPT_SUFFIX = '.embedded-sha256' + +const isNsisUninstallerArtifact = (filePath) => + typeof filePath === 'string' && basename(filePath).endsWith(UNINSTALLER_BASENAME_SUFFIX) + +/** + * Pure relay. Returns a short verdict string for logging and tests. + * Never throws: a relay failure must ship today's installer, not break the build. + */ +function relayNsisUninstaller({ + filePath, + exportPath, + signedPath, + fs = { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } +}) { + if (!isNsisUninstallerArtifact(filePath)) { + return 'not-uninstaller' + } + try { + // Import wins over export: the rebuild pass must embed the signed bytes even + // though it also regenerates an unsigned uninstaller of its own. + if (signedPath) { + if (!fs.existsSync(signedPath)) { + return 'signed-missing' + } + fs.copyFileSync(signedPath, filePath) + const digest = createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') + fs.writeFileSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`, digest) + return 'imported' + } + if (exportPath) { + fs.mkdirSync(dirname(exportPath), { recursive: true }) + fs.copyFileSync(filePath, exportPath) + return 'exported' + } + return 'idle' + } catch (error) { + return `failed: ${error.message}` + } +} + +const VERDICT_MESSAGES = { + imported: (paths) => `embedded the SignPath-signed uninstaller from ${paths.signedPath}`, + exported: (paths) => `exported the unsigned uninstaller to ${paths.exportPath}`, + 'signed-missing': (paths) => + `no signed uninstaller at ${paths.signedPath}; embedding the unsigned one (fail-open)` +} + +/** + * electron-builder `win.signtoolOptions.sign` hook. Called for every Windows + * executable, twice per file (once per signing hash), so it must be cheap for + * non-uninstaller paths and idempotent for the uninstaller. + */ +function signWindowsUninstallerViaSignPath(configuration) { + const paths = { + filePath: configuration?.path, + exportPath: process.env.ORCA_WIN_UNINSTALLER_EXPORT_PATH || undefined, + signedPath: process.env.ORCA_WIN_UNINSTALLER_SIGNED_PATH || undefined + } + const verdict = relayNsisUninstaller(paths) + const message = VERDICT_MESSAGES[verdict] + if (message) { + console.log(`[win-uninstaller-signing] ${message(paths)}`) + } else if (verdict.startsWith('failed')) { + console.warn(`[win-uninstaller-signing] ${verdict}; embedding the unsigned uninstaller.`) + } +} + +module.exports = { + EMBEDDED_RECEIPT_SUFFIX, + UNINSTALLER_BASENAME_SUFFIX, + isNsisUninstallerArtifact, + relayNsisUninstaller, + signWindowsUninstallerViaSignPath +} diff --git a/config/scripts/windows-uninstaller-signing.test.mjs b/config/scripts/windows-uninstaller-signing.test.mjs new file mode 100644 index 00000000000..57ebfbdf786 --- /dev/null +++ b/config/scripts/windows-uninstaller-signing.test.mjs @@ -0,0 +1,235 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + EMBEDDED_RECEIPT_SUFFIX, + isNsisUninstallerArtifact, + relayNsisUninstaller, + signWindowsUninstallerViaSignPath +} = require('./windows-uninstaller-signing.cjs') + +const makeDir = () => mkdtempSync(join(tmpdir(), 'orca-uninstaller-signing-')) + +describe('isNsisUninstallerArtifact', () => { + // The name app-builder-lib's NsisTarget.computeScriptAndSignUninstaller gives + // the intermediate uninstaller; the hook keys off nothing else. + it('matches only electron-builder intermediate uninstallers', () => { + expect(isNsisUninstallerArtifact('C:\\dist\\orca-windows-setup.__uninstaller.exe')).toBe(true) + expect(isNsisUninstallerArtifact('/dist/orca-windows-setup.__uninstaller.exe')).toBe(true) + expect(isNsisUninstallerArtifact('C:\\dist\\win-unpacked\\Orca.exe')).toBe(false) + expect(isNsisUninstallerArtifact('C:\\dist\\orca-windows-setup.exe')).toBe(false) + expect(isNsisUninstallerArtifact(undefined)).toBe(false) + }) +}) + +describe('relayNsisUninstaller', () => { + const writeUninstaller = (dir, contents) => { + const filePath = join(dir, 'orca-windows-setup.__uninstaller.exe') + writeFileSync(filePath, contents) + return filePath + } + + it('ignores every file that is not the uninstaller', () => { + const dir = makeDir() + const filePath = join(dir, 'Orca.exe') + writeFileSync(filePath, 'app') + expect(relayNsisUninstaller({ filePath, exportPath: join(dir, 'out', 'x.exe') })).toBe( + 'not-uninstaller' + ) + }) + + it('exports the unsigned uninstaller, creating the destination directory', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + const exportPath = join(dir, 'uninstaller-signing', 'unsigned', 'orca-uninstaller.exe') + + expect(relayNsisUninstaller({ filePath, exportPath })).toBe('exported') + expect(readFileSync(exportPath, 'utf8')).toBe('unsigned-uninstaller') + }) + + it('overwrites the freshly built uninstaller with the signed bytes', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'rebuild-unsigned') + const signedPath = join(dir, 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'signed')) + writeFileSync(signedPath, 'signpath-signed') + + expect(relayNsisUninstaller({ filePath, signedPath })).toBe('imported') + expect(readFileSync(filePath, 'utf8')).toBe('signpath-signed') + }) + + // The receipt is the evidence gate's only handle on the embedded uninstaller: + // NSIS hides it in a compressed section the bundled 7za cannot read. + it('records the digest of the bytes it handed makensis', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'rebuild-unsigned') + const signedPath = join(dir, 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'signed')) + writeFileSync(signedPath, 'signpath-signed') + + relayNsisUninstaller({ filePath, signedPath }) + + const expected = createHash('sha256').update('signpath-signed').digest('hex') + expect(readFileSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`, 'utf8')).toBe(expected) + }) + + it('leaves no receipt when the signed uninstaller never came back', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + const signedPath = join(dir, 'absent', 'orca-uninstaller.exe') + + relayNsisUninstaller({ filePath, signedPath }) + + expect(existsSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`)).toBe(false) + }) + + // Import wins so the rebuild pass embeds the signed bytes even though it also + // regenerates an unsigned uninstaller of its own. + it('prefers importing over exporting when both are configured', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'rebuild-unsigned') + const signedPath = join(dir, 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'signed')) + writeFileSync(signedPath, 'signpath-signed') + + expect( + relayNsisUninstaller({ filePath, signedPath, exportPath: join(dir, 'out', 'x.exe') }) + ).toBe('imported') + expect(readFileSync(filePath, 'utf8')).toBe('signpath-signed') + }) + + // Fail-open: a missing or unwritable relay must leave the build with today's + // unsigned uninstaller, never throw. + it('leaves the unsigned uninstaller in place when no signed copy came back', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + + expect( + relayNsisUninstaller({ filePath, signedPath: join(dir, 'absent', 'orca-uninstaller.exe') }) + ).toBe('signed-missing') + expect(readFileSync(filePath, 'utf8')).toBe('unsigned-uninstaller') + }) + + it('swallows filesystem errors instead of failing the build', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + const fs = { + existsSync: () => true, + mkdirSync: () => {}, + copyFileSync: () => { + throw new Error('EACCES') + } + } + + expect(relayNsisUninstaller({ filePath, exportPath: join(dir, 'x.exe'), fs })).toBe( + 'failed: EACCES' + ) + }) + + it('does nothing when neither relay path is configured (local builds)', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + + expect(relayNsisUninstaller({ filePath })).toBe('idle') + expect(readFileSync(filePath, 'utf8')).toBe('unsigned-uninstaller') + }) +}) + +// Why a suite of its own: this is the function electron-builder actually calls, +// and it runs inside `Build Windows release artifacts`, which has no +// continue-on-error. If it throws, the release job dies before a single +// SignPath request is made. Nothing else in the chain guards that. +describe('signWindowsUninstallerViaSignPath', () => { + const RELAY_VARS = ['ORCA_WIN_UNINSTALLER_EXPORT_PATH', 'ORCA_WIN_UNINSTALLER_SIGNED_PATH'] + + const withEnv = (env, run) => { + const saved = Object.fromEntries(RELAY_VARS.map((key) => [key, process.env[key]])) + const apply = (values) => { + for (const key of RELAY_VARS) { + if (values[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = values[key] + } + } + } + apply({ ...Object.fromEntries(RELAY_VARS.map((key) => [key, undefined])), ...env }) + try { + return run() + } finally { + apply(saved) + } + } + + const writeBuiltUninstaller = (dir) => { + const filePath = join(dir, 'orca-windows-setup.__uninstaller.exe') + writeFileSync(filePath, 'built-by-makensis') + return filePath + } + + it.each([ + ['a missing configuration', undefined], + ['a configuration with no path', {}], + ['a non-uninstaller path', { path: 'C:\\dist\\win-unpacked\\Orca.exe' }] + ])('never throws on %s', (_label, configuration) => { + withEnv({ ORCA_WIN_UNINSTALLER_EXPORT_PATH: join(makeDir(), 'out', 'x.exe') }, () => { + expect(() => signWindowsUninstallerViaSignPath(configuration)).not.toThrow() + }) + }) + + // electron-builder calls the hook once per signing hash (sha1 then sha256), + // so both legs have to survive running twice over the same file. + it('is idempotent across the sha1 and sha256 invocations on both legs', () => { + const dir = makeDir() + const filePath = writeBuiltUninstaller(dir) + const exportPath = join(dir, 'relay', 'unsigned', 'orca-uninstaller.exe') + + withEnv({ ORCA_WIN_UNINSTALLER_EXPORT_PATH: exportPath }, () => { + signWindowsUninstallerViaSignPath({ path: filePath }) + signWindowsUninstallerViaSignPath({ path: filePath }) + }) + expect(readFileSync(exportPath, 'utf8')).toBe('built-by-makensis') + + const signedPath = join(dir, 'relay', 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'relay', 'signed'), { recursive: true }) + writeFileSync(signedPath, 'signpath-signed') + + withEnv({ ORCA_WIN_UNINSTALLER_SIGNED_PATH: signedPath }, () => { + signWindowsUninstallerViaSignPath({ path: filePath }) + signWindowsUninstallerViaSignPath({ path: filePath }) + }) + expect(readFileSync(filePath, 'utf8')).toBe('signpath-signed') + expect(readFileSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`, 'utf8')).toBe( + createHash('sha256').update('signpath-signed').digest('hex') + ) + }) + + // An unwritable destination is the realistic filesystem failure, and it must + // cost the uninstaller signature rather than the release job. + it('never throws when the export destination cannot be created', () => { + const dir = makeDir() + const filePath = writeBuiltUninstaller(dir) + const blocker = join(dir, 'blocker') + writeFileSync(blocker, 'not a directory') + + withEnv({ ORCA_WIN_UNINSTALLER_EXPORT_PATH: join(blocker, 'sub', 'x.exe') }, () => { + expect(() => signWindowsUninstallerViaSignPath({ path: filePath })).not.toThrow() + }) + expect(readFileSync(filePath, 'utf8')).toBe('built-by-makensis') + }) + + it('does nothing when neither relay variable is set (local Windows builds)', () => { + const dir = makeDir() + const filePath = writeBuiltUninstaller(dir) + + withEnv({}, () => { + expect(() => signWindowsUninstallerViaSignPath({ path: filePath })).not.toThrow() + }) + expect(readFileSync(filePath, 'utf8')).toBe('built-by-makensis') + }) +}) diff --git a/docs/reference/windows-cmd-shim-resolution.md b/docs/reference/windows-cmd-shim-resolution.md new file mode 100644 index 00000000000..c17380e800d --- /dev/null +++ b/docs/reference/windows-cmd-shim-resolution.md @@ -0,0 +1,77 @@ +# Resolving Windows `.cmd` shims past cmd.exe + +Node refuses to spawn a `.cmd`/`.bat` target without a shell (the +CVE-2024-27980 mitigation), so `resolveSpawn` has to make `cmd.exe` the program +and hand it `/d /v:off /s /c ""`. For an agent CLI that +means a long `cmd.exe /c` line whose caret-escaped payload is natural-language +prompt text — which Microsoft Defender for Endpoint's command-line model scores +as obfuscation. `codex.cmd` appeared in the spawn cluster of an MDE incident +against Orca for exactly this reason. + +`src/shared/child-process/windows-cmd-shim-resolution.ts` sidesteps it. npm's +`cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose entire body is +"find a Node interpreter and run this script". Reading one lets `resolveSpawn` +spawn `node.exe - - - `) - }) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as AddressInfo).port - return { - sourceUrl: `http://127.0.0.1:${port}/source`, - close: () => closeServer(server) - } -} - async function startBrowserWindowCloseServer(): Promise<{ url: string sourceUrl: string @@ -281,8 +204,8 @@ async function clickBrowserLink( browserTabId: string, selector: string, options: { - modifiers?: ('meta' | 'control')[] - button?: 'left' | 'middle' + modifiers?: ('meta' | 'control' | 'shift')[] + button?: 'left' | 'middle' | 'right' frameSelector?: string } = {} ): Promise { @@ -317,21 +240,31 @@ async function clickBrowserLink( if (!point) { throw new Error(`Missing browser link ${targetSelector}`) } - await webview.sendInputEvent({ type: 'mouseMove', modifiers: inputModifiers, ...point }) - await webview.sendInputEvent({ - type: 'mouseDown', - button, - clickCount: 1, - modifiers: inputModifiers, - ...point - }) - await webview.sendInputEvent({ - type: 'mouseUp', - button, - clickCount: 1, - modifiers: inputModifiers, - ...point - }) + const holdShift = inputModifiers.includes('shift') + if (holdShift) { + await webview.sendInputEvent({ type: 'keyDown', keyCode: 'Shift', modifiers: ['shift'] }) + } + try { + await webview.sendInputEvent({ type: 'mouseMove', modifiers: inputModifiers, ...point }) + await webview.sendInputEvent({ + type: 'mouseDown', + button, + clickCount: 1, + modifiers: inputModifiers, + ...point + }) + await webview.sendInputEvent({ + type: 'mouseUp', + button, + clickCount: 1, + modifiers: inputModifiers, + ...point + }) + } finally { + if (holdShift) { + await webview.sendInputEvent({ type: 'keyUp', keyCode: 'Shift' }) + } + } }, { targetBrowserTabId: browserTabId, @@ -343,21 +276,43 @@ async function clickBrowserLink( ) } -async function expectBrowserTabActive( +async function waitForTabIdByExactTitle( page: Parameters[0], title: string -): Promise { +): Promise { const resolveTabId = (): Promise => page.locator('[data-tab-id]').evaluateAll((tabs, exactTitle) => { const tab = tabs.find((candidate) => candidate.textContent?.trim() === exactTitle) return tab?.getAttribute('data-tab-id') ?? null }, title) await expect.poll(resolveTabId, { timeout: 10_000 }).not.toBeNull() - const tabId = await resolveTabId() - expect(tabId).toBeTruthy() + return (await resolveTabId()) as string +} + +async function expectBrowserTabActive( + page: Parameters[0], + title: string +): Promise { + const tabId = await waitForTabIdByExactTitle(page, title) await expect(page.locator(`[data-browser-overlay-tab-id="${tabId}"]`)).toHaveCSS('opacity', '1') } +async function expectBrowserTabOpenedInBackground( + page: Parameters[0], + sourceTabId: string, + title: string +): Promise { + const openedTabId = await waitForTabIdByExactTitle(page, title) + await expect(page.locator(`[data-browser-overlay-tab-id="${sourceTabId}"]`)).toHaveCSS( + 'opacity', + '1' + ) + await expect(page.locator(`[data-browser-overlay-tab-id="${openedTabId}"]`)).toHaveCSS( + 'opacity', + '0' + ) +} + async function readBrowserInputValue( page: Parameters[0], browserTabId: string @@ -680,7 +635,7 @@ test.describe('Browser Tab', () => { } }) - test('every new-tab link gesture activates an Orca tab and never a native window', async ({ + test('new-tab link gestures follow Chrome foreground and background behavior', async ({ electronApp, orcaPage }) => { @@ -698,38 +653,51 @@ test.describe('Browser Tab', () => { const baseWindowCount = await electronApp.evaluate( ({ BaseWindow }) => BaseWindow.getAllWindows().length ) - // A plain target=_blank click is a new-tab request, in the main frame and in an iframe; - // the source tab must stay put rather than navigate away under it. + // A plain main-frame target=_blank click must not navigate the source tab away. const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`) - await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') - await expectBrowserTabActive(orcaPage, 'Linked destination') + await clickBrowserLink(orcaPage, sourceTab!.id, '#blank-link') + await expectBrowserTabActive(orcaPage, 'Blank target destination') await expect(sourceTabLocator).toContainText('Source page') await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + // Context-menu links keep the source visible until the new tab is selected. + await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link', { button: 'right' }) + await orcaPage + .getByRole('menuitem', { name: 'Open Link In Orca Browser', exact: true }) + .click() + await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Linked destination') await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', { frameSelector: '#link-frame' }) await expectBrowserTabActive(orcaPage, 'Frame destination') - await expect(sourceTabLocator).toContainText('Source page') await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', { frameSelector: '#link-frame', modifiers: process.platform === 'darwin' ? ['meta'] : ['control'] }) - await expectBrowserTabActive(orcaPage, 'Frame modifier destination') - await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + await expectBrowserTabOpenedInBackground( + orcaPage, + sourceTab!.id, + 'Frame modifier destination' + ) await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-middle-link', { button: 'middle', frameSelector: '#link-frame' }) - await expectBrowserTabActive(orcaPage, 'Frame middle destination') - await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Frame middle destination') await clickBrowserLink(orcaPage, sourceTab!.id, '#modifier-link', { modifiers: process.platform === 'darwin' ? ['meta'] : ['control'] }) - await expectBrowserTabActive(orcaPage, 'Modifier destination') + await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Modifier destination') + + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-shift-middle-link', { + button: 'middle', + modifiers: ['shift'], + frameSelector: '#link-frame' + }) + await expectBrowserTabActive(orcaPage, 'Frame shift middle destination') await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) const tabCountBeforeCancelledClick = await orcaPage.locator('[data-tab-id]').count() @@ -740,7 +708,7 @@ test.describe('Browser Tab', () => { await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(tabCountBeforeCancelledClick) await clickBrowserLink(orcaPage, sourceTab!.id, '#middle-link', { button: 'middle' }) - await expectBrowserTabActive(orcaPage, 'Middle-click destination') + await expectBrowserTabOpenedInBackground(orcaPage, sourceTab!.id, 'Middle-click destination') await expect .poll(() => electronApp.evaluate(({ BaseWindow }) => BaseWindow.getAllWindows().length), { timeout: 5_000 diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts index 7057a38babd..106e2ea778e 100644 --- a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -263,12 +263,24 @@ afterEach(() => { describe('release checkout materialization', () => { it('single-flights concurrent consumers of one release identity', async () => { const cacheRoot = temporaryCacheRoot() + let publications = 0 + const options = { + cacheRoot, + testHooks: { + populateStaging: async (context: CheckoutStagingContext) => { + publications++ + await populateMinimalStaging(context) + } + } + } const checkouts = await Promise.all([ - materializeReleaseCheckout('v1.4.190', { cacheRoot }), - materializeReleaseCheckout('v1.4.190', { cacheRoot }), - materializeReleaseCheckout('v1.4.190', { cacheRoot }) + materializeReleaseCheckout('v1.4.190', options), + materializeReleaseCheckout('v1.4.190', options), + materializeReleaseCheckout('v1.4.190', options) ]) + expect(publications).toBe(1) + expect(new Set(checkouts.map(({ root }) => root))).toHaveLength(1) expect(relative(cacheRoot, checkouts[0]!.root)).not.toMatch(/^\.\./) }) @@ -291,18 +303,29 @@ describe('release checkout materialization', () => { ) const cacheRoot = temporaryCacheRoot() - const first = await materializeReleaseCheckout(firstRef, { cacheRoot }) + const options = { cacheRoot, testHooks: { populateStaging: populateMinimalStaging } } + const first = await materializeReleaseCheckout(firstRef, options) const dependency = join(first.root, 'delayed-dependency.mjs') const entry = join(first.root, 'delayed-entry.mjs') + const importStarted = join(cacheRoot, 'import-started') + const continueImport = join(cacheRoot, 'continue-import') writeFileSync(dependency, "export const loaded = 'first-release'\n") writeFileSync( entry, - 'await new Promise((resolve) => setTimeout(resolve, 100))\n' + + "import { existsSync, writeFileSync } from 'node:fs'\n" + + `writeFileSync(${JSON.stringify(importStarted)}, '')\n` + + `while (!existsSync(${JSON.stringify(continueImport)})) await new Promise((resolve) => setTimeout(resolve, 10))\n` + "export const loaded = (await import('./delayed-dependency.mjs')).loaded\n" ) const loading = importReleaseCheckoutModule(first, '/delayed-entry.mjs') - const second = await materializeReleaseCheckout(secondRef, { cacheRoot }) + let second: ReleaseCheckout + try { + await waitForFile(importStarted, 5_000) + second = await materializeReleaseCheckout(secondRef, options) + } finally { + writeFileSync(continueImport, '') + } await expect(loading).resolves.toMatchObject({ loaded: 'first-release' }) expect(first.root).not.toBe(second.root) @@ -311,7 +334,10 @@ describe('release checkout materialization', () => { it('causally single-flights a rival process before publishing an in-use checkout', async () => { const cacheRoot = temporaryCacheRoot() const scratch = temporaryCacheRoot() - const published = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) + const published = await materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { populateStaging: populateMinimalStaging } + }) await expect(runContentionPhase(published, scratch, 'locked', false)).resolves.toBe(true) // In the same causally acknowledged interleaving, a no-lock materializer diff --git a/tests/e2e/file-explorer-watch-refresh.spec.ts b/tests/e2e/file-explorer-watch-refresh.spec.ts index d8a1bc1e4e7..85fbd66409e 100644 --- a/tests/e2e/file-explorer-watch-refresh.spec.ts +++ b/tests/e2e/file-explorer-watch-refresh.spec.ts @@ -35,7 +35,7 @@ test('refreshes the visible tree after external Windows file changes', async ({ const row = (name: string) => orcaPage .locator('[data-file-explorer-row]') - .filter({ hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }) + .filter({ has: orcaPage.getByText(name, { exact: true }) }) rmSync(originalPath, { force: true }) rmSync(renamedPath, { force: true }) diff --git a/tests/e2e/helpers/browser-link-server.ts b/tests/e2e/helpers/browser-link-server.ts new file mode 100644 index 00000000000..81debdc5859 --- /dev/null +++ b/tests/e2e/helpers/browser-link-server.ts @@ -0,0 +1,100 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + ) +} + +export async function startBrowserLinkServer(): Promise<{ + sourceUrl: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/destination') { + response.end( + `Linked destinationDestination Return` + ) + return + } + if (pathname === '/blank-destination') { + response.end( + 'Blank target destinationBlank target destination' + ) + return + } + if (pathname === '/frame-destination') { + response.end( + `Frame destinationFrame destination Return` + ) + return + } + if (pathname === '/frame-modifier-destination') { + response.end( + 'Frame modifier destinationFrame modifier destination' + ) + return + } + if (pathname === '/frame-middle-destination') { + response.end( + 'Frame middle destinationFrame middle destination' + ) + return + } + if (pathname === '/frame') { + response.end( + `${request.url?.includes('shift-middle') ? 'Frame shift middle destination' : ''}Open frame destinationOpen frame modifier destinationOpen frame middle destinationOpen foreground frame tab` + ) + return + } + if (pathname === '/modifier-destination') { + response.end( + 'Modifier destinationModifier destination' + ) + return + } + if (pathname === '/middle-destination') { + response.end( + 'Middle-click destinationMiddle-click destination' + ) + return + } + response.end(` + + + ${request.url?.includes('shift-middle') ? 'Shift middle destination' : 'Source page'} + + Open destination + Open blank target destination + Open with modifier + Open with middle click + Open foreground tab + Handle in page + + + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + sourceUrl: `http://127.0.0.1:${port}/source`, + close: () => closeServer(server) + } +} diff --git a/tests/e2e/helpers/git-status-retry-barrier.ts b/tests/e2e/helpers/git-status-retry-barrier.ts new file mode 100644 index 00000000000..e166313d003 --- /dev/null +++ b/tests/e2e/helpers/git-status-retry-barrier.ts @@ -0,0 +1,61 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' + +type StatusArgs = { worktreePath?: string; admissionTier?: string } +type StatusHandler = (event: unknown, args?: StatusArgs) => unknown +type RetryBarrier = { + captured: boolean + release: () => void + original: StatusHandler +} +type BarrierScope = typeof globalThis & { __gitStatusRetryBarrier?: RetryBarrier } + +export async function installGitStatusRetryBarrier( + app: ElectronApplication, + repoPath: string +): Promise { + await app.evaluate(({ ipcMain }, repoPath) => { + const scope = globalThis as BarrierScope + const handlers = (ipcMain as unknown as { _invokeHandlers: Map }) + ._invokeHandlers + const original = handlers.get('git:status') + if (!original || scope.__gitStatusRetryBarrier) { + throw new Error('Git status handler unavailable or retry barrier already installed') + } + let release!: () => void + const pending = new Promise((resolve) => { + release = resolve + }) + const state: RetryBarrier = { captured: false, release, original } + scope.__gitStatusRetryBarrier = state + handlers.set('git:status', async (event, args) => { + if ( + !state.captured && + args?.worktreePath === repoPath && + args.admissionTier === 'interactive' + ) { + state.captured = true + await pending + } + return original(event, args) + }) + }, repoPath) +} + +export async function hasCapturedGitStatusRetry(app: ElectronApplication): Promise { + return app.evaluate(() => (globalThis as BarrierScope).__gitStatusRetryBarrier?.captured ?? false) +} + +export async function restoreGitStatusRetryHandler(app: ElectronApplication): Promise { + await app.evaluate(({ ipcMain }) => { + const scope = globalThis as BarrierScope + const state = scope.__gitStatusRetryBarrier + if (!state) { + return + } + const handlers = (ipcMain as unknown as { _invokeHandlers: Map }) + ._invokeHandlers + handlers.set('git:status', state.original) + state.release() + delete scope.__gitStatusRetryBarrier + }) +} diff --git a/tests/e2e/helpers/git-status-retry-barrier.unit.test.ts b/tests/e2e/helpers/git-status-retry-barrier.unit.test.ts new file mode 100644 index 00000000000..74bdd8d8159 --- /dev/null +++ b/tests/e2e/helpers/git-status-retry-barrier.unit.test.ts @@ -0,0 +1,39 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { describe, expect, it, vi } from 'vitest' +import { + hasCapturedGitStatusRetry, + installGitStatusRetryBarrier, + restoreGitStatusRetryHandler +} from './git-status-retry-barrier' + +describe('Git status retry barrier', () => { + it('holds the target interactive request and restores the real handler on cleanup', async () => { + const original = vi.fn(async (_event: unknown, args: unknown) => args) + const handlers = new Map([['git:status', original]]) + const app = { + evaluate: (callback: (electron: unknown, arg?: unknown) => unknown, arg?: unknown) => + Promise.resolve(callback({ ipcMain: { _invokeHandlers: handlers } }, arg)) + } as unknown as ElectronApplication + await installGitStatusRetryBarrier(app, 'target-repo') + try { + const handler = handlers.get('git:status')! + const background = { worktreePath: 'target-repo', admissionTier: 'background' } + const otherRepo = { worktreePath: 'another-repo', admissionTier: 'interactive' } + await expect(handler({}, background)).resolves.toEqual(background) + await expect(handler({}, otherRepo)).resolves.toEqual(otherRepo) + expect(await hasCapturedGitStatusRetry(app)).toBe(false) + + const retry = { worktreePath: 'target-repo', admissionTier: 'interactive' } + const event = {} + const pending = handler(event, retry) + expect(await hasCapturedGitStatusRetry(app)).toBe(true) + expect(original).toHaveBeenCalledTimes(2) + await restoreGitStatusRetryHandler(app) + await expect(pending).resolves.toEqual(retry) + expect(original).toHaveBeenLastCalledWith(event, retry) + expect(handlers.get('git:status')).toBe(original) + } finally { + await restoreGitStatusRetryHandler(app) + } + }) +}) diff --git a/tests/e2e/helpers/paired-client-window-reveal.ts b/tests/e2e/helpers/paired-client-window-reveal.ts index 302d573c3d1..1ec5b635d77 100644 --- a/tests/e2e/helpers/paired-client-window-reveal.ts +++ b/tests/e2e/helpers/paired-client-window-reveal.ts @@ -29,22 +29,24 @@ export function assertPairedClientWindowRevealed(report: PairedClientWindowRevea export type PairedClientWindowFocusReport = PairedClientWindowRevealReport & { isFocused: boolean } /** - * Brings a paired client to the front, which a launched-but-background window never is. Main-side - * policies that ask whether the reader is looking at a WebContents read the OS focus state, so a - * spec driving real presses through such a policy has to put the window there first. + * Native-focus coverage must run on an isolated display or CI, never in background mode. */ export async function focusPairedClientWindow( client: RevealablePairedClient, { timeoutMs = 15_000 }: { timeoutMs?: number } = {} ): Promise { + await client.app.evaluate(() => { + if (process.env.ORCA_BACKGROUND_LAUNCH === '1') { + throw new Error('Native focus is forbidden by ORCA_BACKGROUND_LAUNCH') + } + }) const revealed = await revealPairedClientWindow(client) const deadline = Date.now() + timeoutMs let isFocused = false while (!isFocused) { isFocused = await client.app.evaluate(({ app, BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0] - // Why steal: nothing else in the run is asking for the front, and the window manager keeps - // the launching terminal there otherwise. + // Native-focus coverage requires a dedicated foreground session. app.focus({ steal: true }) window?.focus() return window?.isFocused() ?? false @@ -61,6 +63,9 @@ export async function revealPairedClientWindow( client: RevealablePairedClient ): Promise { const report = await client.app.evaluate(({ BrowserWindow }) => { + if (process.env.ORCA_BACKGROUND_LAUNCH === '1') { + throw new Error('Window reveal is forbidden by ORCA_BACKGROUND_LAUNCH') + } const windows = BrowserWindow.getAllWindows() const window = windows[0] const wasVisible = window?.isVisible() ?? false diff --git a/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts b/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts index dfb83e4c441..706088e6762 100644 --- a/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts +++ b/tests/e2e/helpers/paired-client-window-reveal.unit.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { assertPairedClientWindowRevealed } from './paired-client-window-reveal' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + assertPairedClientWindowRevealed, + focusPairedClientWindow, + revealPairedClientWindow, + type RevealablePairedClient +} from './paired-client-window-reveal' describe('assertPairedClientWindowRevealed', () => { it('accepts a window that the reveal made visible', () => { @@ -42,3 +47,41 @@ describe('assertPairedClientWindowRevealed', () => { ).toThrow(/stayed hidden after showInactive\(\)/) }) }) + +describe('paired client background safety', () => { + afterEach(() => vi.unstubAllEnvs()) + + function makeClient() { + const showInactive = vi.fn() + const focus = vi.fn() + const getAllWindows = vi.fn(() => [{ isVisible: () => false, showInactive, focus }]) + const evaluate = vi.fn(async (callback) => + callback({ + app: { focus }, + BrowserWindow: { getAllWindows } + }) + ) + const client = { + app: { evaluate }, + page: { waitForFunction: vi.fn() } + } as unknown as RevealablePairedClient + return { client, showInactive, focus, getAllWindows } + } + + it('rejects an explicit reveal before touching native windows', async () => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + const { client, getAllWindows, showInactive } = makeClient() + await expect(revealPairedClientWindow(client)).rejects.toThrow('Window reveal is forbidden') + expect(getAllWindows).not.toHaveBeenCalled() + expect(showInactive).not.toHaveBeenCalled() + }) + + it.each(['0', '1'])('rejects focus in background mode with foreground=%s', async (foreground) => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + vi.stubEnv('ORCA_E2E_FOREGROUND', foreground) + const { client, focus, getAllWindows } = makeClient() + await expect(focusPairedClientWindow(client)).rejects.toThrow('Native focus is forbidden') + expect(getAllWindows).not.toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() + }) +}) diff --git a/tests/e2e/helpers/ssh-config-host-picker.ts b/tests/e2e/helpers/ssh-config-host-picker.ts index 9b7d7b2d34a..bad31c818d9 100644 --- a/tests/e2e/helpers/ssh-config-host-picker.ts +++ b/tests/e2e/helpers/ssh-config-host-picker.ts @@ -73,23 +73,36 @@ export async function closeSettingsPage(page: Page): Promise { export async function closeOpenDialogs(page: Page): Promise { for (let attempt = 0; attempt < 5; attempt += 1) { + // Nested dialogs can finish their exit animations in different frames. + await expect(page.locator('[role="dialog"][data-state="closed"]')).toHaveCount(0, { + timeout: 3_000 + }) const dialogCount = await page.getByRole('dialog').count() if (dialogCount === 0) { return } - const dialog = page.getByRole('dialog').last() - const cancelOrBack = dialog.getByRole('button', { name: /^(Cancel|Back)$/ }) - await ((await cancelOrBack - .first() - .isVisible() - .catch(() => false)) - ? cancelOrBack.first().click() - : page.keyboard.press('Escape')) - await expect - .poll(async () => page.getByRole('dialog').count(), { timeout: 3_000 }) - .toBeLessThan(dialogCount) - .catch(() => undefined) + const dialogId = await page.getByRole('dialog').last().getAttribute('id') + if (!dialogId) { + throw new Error('Open dialog is missing its Radix identity') + } + const dialog = page.locator(`[role="dialog"][id=${JSON.stringify(dialogId)}]`) + const back = dialog.getByRole('button', { name: 'Back', exact: true }) + if (await back.isVisible()) { + await back.click() + // The picker and host form reuse the same Radix dialog. + await expect(back).toBeHidden({ timeout: 3_000 }) + await expect(dialog.getByRole('button', { name: 'Cancel', exact: true })).toBeVisible({ + timeout: 3_000 + }) + continue + } + const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true }) + await ((await cancel.isVisible()) ? cancel.click() : page.keyboard.press('Escape')) + // Hidden Electron windows can park CSS exits before their first compositor frame. + await page.screenshot({ animations: 'disabled' }) + await expect(dialog).toBeHidden({ timeout: 3_000 }) } + await expect(page.getByRole('dialog')).toHaveCount(0, { timeout: 3_000 }) } /** Leave settings / overlays so the main shell (Add Project) is reachable. */ diff --git a/tests/e2e/helpers/startup-exec-readiness-oracle.ts b/tests/e2e/helpers/startup-exec-readiness-oracle.ts index 577702c8ea0..7aa56e38863 100644 --- a/tests/e2e/helpers/startup-exec-readiness-oracle.ts +++ b/tests/e2e/helpers/startup-exec-readiness-oracle.ts @@ -9,6 +9,7 @@ import type { import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id' import { expect } from './orca-app' import { getTerminalContent, waitForActivePanePtyId } from './terminal' +import { readFreshTerminalInventory } from './terminal-inventory-observation' const RECOVERY_DEADLINE_MS = 8_000 @@ -76,10 +77,6 @@ function count(text: string, marker: string): number { return text.split(marker).length - 1 } -function isTransientPtyLivenessError(error: unknown): boolean { - return error instanceof Error && error.message.includes('terminal_liveness_unavailable') -} - async function expectSingleOwningPty( page: Page, worktreeId: string, @@ -90,24 +87,15 @@ async function expectSingleOwningPty( await expect .poll( async () => { - try { - const listed = await callStartupExecRuntime( - page, - 'terminal.list', - { - worktree: `id:${worktreeId}`, - requireFreshPtyLiveness: true - } - ) - return listed.terminals - .filter((candidate) => candidate.tabId === tabId) - .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) - } catch (error) { - if (isTransientPtyLivenessError(error)) { - return [] - } - throw error - } + const listed = await readFreshTerminalInventory(() => + callStartupExecRuntime(page, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + ) + return (listed?.terminals ?? []) + .filter((candidate) => candidate.tabId === tabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) }, { timeout: 30_000 } ) diff --git a/tests/e2e/helpers/terminal-inventory-observation.ts b/tests/e2e/helpers/terminal-inventory-observation.ts new file mode 100644 index 00000000000..0fcefdb23c8 --- /dev/null +++ b/tests/e2e/helpers/terminal-inventory-observation.ts @@ -0,0 +1,14 @@ +import type { RuntimeTerminalListResult } from '../../../src/shared/runtime-types' + +export async function readFreshTerminalInventory( + read: () => Promise +): Promise { + try { + return await read() + } catch (error) { + if (error instanceof Error && error.message.includes('terminal_liveness_unavailable')) { + return null + } + throw error + } +} diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index ef331ee6277..fda18fc74c6 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -16,6 +16,7 @@ import { launchPairedElectronClient } from './helpers/paired-electron-client' import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' +import { readFreshTerminalInventory } from './helpers/terminal-inventory-observation' const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-materialize-')) const fixturePath = path.join(scratch, 'materialize-terminal.mjs') @@ -316,18 +317,17 @@ async function runMaterializationJourney( await tab.click() await expect.poll(() => getTerminalContent(page), { timeout: 10_000 }).toContain(marker) - const listed = await callRuntime( - page, - environmentId, - 'terminal.list', - { - worktree: `id:${worktreeId}`, - requireFreshPtyLiveness: true - } - ) - expect( - listed.terminals.filter((terminal) => terminal.tabId === created.tab.parentTabId) - ).toHaveLength(1) + await expect + .poll(async () => { + const listed = await readFreshTerminalInventory(() => + callRuntime(page, environmentId, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + ) + return listed?.terminals.filter((terminal) => terminal.tabId === created.tab.parentTabId) + }) + .toHaveLength(1) await callRuntime(page, environmentId, 'terminal.closeTab', { terminal: replacementHandle }) } @@ -354,15 +354,7 @@ test('materializes a stopped terminal on reconnect from a headed paired host', a } }) -// Why fixme: this journey's fault injection cannot be set up on a headless `orca serve` host. -// `terminal.stopExact` keeps returning terminal_exact_stop_failed because stopAndWait's -// keep-history verification window expires before the parked PTY is observed gone, so the pane -// never reaches pending-handle and the reconnect behavior is never exercised. That precondition -// fails identically on this PR's base, so it is a pre-existing exact-stop defect rather than a -// reconnect-activation one. The recovery behavior itself was confirmed by hand in this topology -// (the host materializes the pending surface and the client rebinds to the replacement PTY); -// re-enable once exact stop settles deterministically against a serve host. -test.fixme('materializes a stopped terminal on reconnect from a headless folder host', async ({ +test('materializes a stopped terminal on reconnect from a headless folder host', async ({ testRepoPath }, testInfo) => { test.setTimeout(150_000) diff --git a/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts b/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts index 4406ebd2f18..2c81b76f077 100644 --- a/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts +++ b/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts @@ -1,3 +1,4 @@ +import { runProcess } from '../../src/shared/child-process/run-process' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -101,11 +102,26 @@ async function minimizeHeadedHost(electronApp: ElectronApplication, page: Page): .poll(() => host.evaluate((window) => ({ backgroundThrottling: window.webContents.getBackgroundThrottling(), - minimized: window.isMinimized(), - visible: window.isVisible() + minimized: window.isMinimized() })) ) - .toEqual({ backgroundThrottling: true, minimized: true, visible: false }) + .toEqual({ backgroundThrottling: true, minimized: true }) + // Linux reports isVisible/document visibility differently; the window manager owns iconification. + if (process.platform === 'linux') { + const nativeId = await host.evaluate((window) => window.getNativeWindowHandle().readUInt32LE(0)) + await expect + .poll(async () => { + const result = await runProcess({ + program: 'xprop', + args: ['-id', String(nativeId), '_NET_WM_STATE'], + timeoutMs: 5_000 + }) + return result.stdout + }) + .toContain('_NET_WM_STATE_HIDDEN') + } else { + await expect.poll(() => page.evaluate(() => document.visibilityState)).toBe('hidden') + } } async function restoreHeadedHost(electronApp: ElectronApplication, page: Page): Promise { diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts index 28a5e7758f2..85f3710b308 100644 --- a/tests/e2e/source-control-large-file-count.spec.ts +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -20,6 +20,11 @@ import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { + hasCapturedGitStatusRetry, + installGitStatusRetryBarrier, + restoreGitStatusRetryHandler +} from './helpers/git-status-retry-barrier' import { createLargeFileCountRepo, removeLargeFileCountRepo, @@ -434,13 +439,17 @@ test.describe('Source Control large file count (#8013)', () => { ) expect(hugeState).not.toBeNull() - // Why: watcher refreshes stay parked while huge; the visible Retry is the - // explicit recovery path after the underlying change count drops. - removeLargeFileCountUntrackedTree(fixture.repoPath) - await expect(tooManyChangesBanner).toBeVisible() const retryButton = tooManyChangesBanner.locator('..').getByRole('button', { name: 'Retry' }) await expect(retryButton).toBeVisible() - await retryButton.click() + // Keep automatic refreshes from removing Retry before its real request starts. + await installGitStatusRetryBarrier(electronApp, fixture.repoPath) + try { + await retryButton.click() + await expect.poll(() => hasCapturedGitStatusRetry(electronApp)).toBe(true) + removeLargeFileCountUntrackedTree(fixture.repoPath) + } finally { + await restoreGitStatusRetryHandler(electronApp) + } await expect(tooManyChangesBanner).not.toBeVisible() await expect .poll(() => diff --git a/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts b/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts index 2fb90a9c992..4344f94adaf 100644 --- a/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts +++ b/tests/e2e/terminal-hangul-terminating-digit-native.spec.ts @@ -3,20 +3,18 @@ * the pty. Written to reproduce #15299, where a digit typed straight after a Hangul syllable was * dropped under Wayland but not under X11. * - * THIS DOES NOT RUN IN CI. It is gated on ORCA_E2E_NATIVE_IBUS_HANGUL=1 and needs a compositor - * session that CI does not have, so it is a manual reproduction harness rather than coverage. - * That is stated plainly because this repo already carries native IME specs that are skipped - * everywhere and were mistaken for coverage they never provided. + * CI runs the default xdotool injector under X11, checking exact Hangul-plus-digit PTY bytes. + * That path passed even before the Wayland fix; it does not prove #15299 is fixed. + * Reproducing #15299 still requires the nested Wayland session below. * - * To run it, on a machine with gnome-shell and ibus-hangul: + * To run the Wayland reproduction on a machine with gnome-shell and ibus-hangul: * * Xvfb :65 -extension GLX & * DISPLAY=:65 gnome-shell --nested --wayland # nested, NOT --headless * ORCA_E2E_NATIVE_IBUS_HANGUL=1 ORCA_E2E_IME_INJECTOR=nested npx playwright test \ * tests/e2e/terminal-hangul-terminating-digit-native.spec.ts * - * Eight things that decide whether a run is real or a silent false negative, each of which cost a - * failed attempt: + * Nested Wayland prerequisites: * * - Nested, not headless. A headless mutter never answers RemoteDesktop.CreateSession, so there * is no way to inject input; nested makes the whole compositor an X window that xdotool can @@ -45,6 +43,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' +import { appendImeEngagementReceipt } from './terminal-ime-engagement-receipt' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { focusActiveTerminalInput, @@ -232,6 +231,11 @@ test.describe('Hangul terminating digit @headful', () => { } receivedBytes = await waitForTerminalImeBytes(page, reader, 20_000) + expect(receivedBytes.map((hex) => Buffer.from(hex, 'hex').toString('utf8'))).toEqual( + Array.from({ length: REPETITIONS }, () => `${EXPECTED_LINE}\n`) + ) + const trace = await readTerminalImeBoundaryTrace(page) + appendImeEngagementReceipt(testInfo.title, trace) } finally { await writeEvidence(page, testInfo, 'hangul-terminating-digit', { expectedHex, @@ -243,8 +247,5 @@ test.describe('Hangul terminating digit @headful', () => { await sendToTerminal(page, ptyId, '\x03').catch(() => undefined) removeTerminalImeByteReader(reader) } - expect(receivedBytes.map((hex) => Buffer.from(hex, 'hex').toString('utf8'))).toEqual( - Array.from({ length: REPETITIONS }, () => `${EXPECTED_LINE}\n`) - ) }) }) diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts index 5f3bfca177b..8120c2a60e7 100644 --- a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -1,4 +1,4 @@ -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { splitActiveTerminalPane, @@ -22,32 +22,6 @@ type DividerGeometry = { test.use({ seedTestRepo: false }) -async function setFullscreen(electronApp: ElectronApplication, page: Page): Promise { - await expect - .poll(async () => { - try { - return await electronApp.evaluate(({ BrowserWindow }) => { - const window = BrowserWindow.getAllWindows()[0] - if (!window) { - return false - } - if (window.isMinimized()) { - window.restore() - } - window.show() - window.focus() - window.setFullScreen(true) - return window.isFullScreen() - }) - } catch { - return false - } - }) - .toBe(true) - await expect.poll(() => page.evaluate(() => innerWidth >= 1000 && innerHeight >= 700)).toBe(true) - await page.waitForTimeout(1200) -} - async function addTestRepo(page: Page, repoPath: string): Promise { const repoId = await page.evaluate(async (path) => { const result = await window.api.repos.add({ path }) @@ -122,17 +96,21 @@ function gridsMatch(geometry: DividerGeometry): boolean { } test('@headful keeps resizing after the divider loses pointer capture', async ({ - electronApp, orcaPage, testRepoPath }, testInfo) => { - await setFullscreen(electronApp, orcaPage) + // Keep the 260px drag above the fit floor regardless of the CI display resolution. + await orcaPage.setViewportSize({ width: 1600, height: 1000 }) await addTestRepo(orcaPage, testRepoPath) await ensureTerminalVisible(orcaPage, 30_000) await waitForActiveTerminalManager(orcaPage, 30_000) await splitActiveTerminalPane(orcaPage, 'vertical') await waitForPaneCount(orcaPage, 2, 30_000) + await expect + .poll(async () => (await readDividerGeometry(orcaPage)).second.width) + .toBeGreaterThan(400) + const divider = orcaPage.locator('.pane-divider.is-vertical').first() await expect(divider).toBeVisible() const box = await divider.boundingBox() @@ -170,11 +148,11 @@ test('@headful keeps resizing after the divider loses pointer capture', async ({ } element.releasePointerCapture(pointerId) }) + // Pending capture changes are dispatched with the next pointer event. + await orcaPage.mouse.move(startX + 260, startY, { steps: 10 }) await expect .poll(() => divider.evaluate((element) => Number(element.dataset.captureLossCount ?? '0'))) .toBe(1) - - await orcaPage.mouse.move(startX + 260, startY, { steps: 10 }) await orcaPage.mouse.up() await expect.poll(async () => gridsMatch(await readDividerGeometry(orcaPage))).toBe(true) const after = await readDividerGeometry(orcaPage) diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index 69a18e06937..36da44dd357 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -307,7 +307,6 @@ test.describe('Workspace board lane virtualization', () => { }) test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { - test.skip(true, 'Quarantined by https://github.com/stablyai/orca/issues/12415') const statusId = 'virtual-marquee' const emptyStatusId = 'virtual-marquee-start' await orcaPage.evaluate( @@ -380,32 +379,36 @@ test.describe('Workspace board lane virtualization', () => { } // Why: CI can overlay individual lane pixels, so choose a live board-owned point. - const startPoint = await emptyLaneScroll.evaluate((element) => { - const ignored = [ - '[data-workspace-board-card-id]', - 'a', - 'button', - 'input', - 'select', - 'textarea', - '[role="button"]', - '[role="menu"]', - '[role="menuitem"]' - ].join(',') - const rect = element.getBoundingClientRect() - for (let y = Math.ceil(rect.top) + 6; y <= Math.floor(rect.top) + 40; y += 6) { - for (let x = Math.ceil(rect.left) + 8; x <= Math.floor(rect.right) - 8; x += 8) { - const target = document.elementFromPoint(x, y) - if ( - target?.closest('[data-workspace-board-selection-surface]') && - !target.closest(ignored) - ) { - return { x, y } + const findStartPoint = () => + emptyLaneScroll.evaluate((element) => { + const ignored = [ + '[data-workspace-board-card-id]', + 'a', + 'button', + 'input', + 'select', + 'textarea', + '[role="button"]', + '[role="menu"]', + '[role="menuitem"]' + ].join(',') + const rect = element.getBoundingClientRect() + for (let y = Math.ceil(rect.top) + 6; y <= Math.floor(rect.top) + 40; y += 6) { + for (let x = Math.ceil(rect.left) + 8; x <= Math.floor(rect.right) - 8; x += 8) { + const target = document.elementFromPoint(x, y) + if ( + target?.closest('[data-workspace-board-selection-surface]') && + !target.closest(ignored) + ) { + return { x, y } + } } } - } - return null - }) + return null + }) + // The board's clip animation can expose cards before the empty lane accepts pointer hits. + await expect.poll(findStartPoint).not.toBeNull() + const startPoint = await findStartPoint() expect(startPoint, 'the empty start lane must expose board-owned space').not.toBeNull() if (!startPoint) { throw new Error('Expected empty board space for the marquee start') diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index d61d96847a0..07f9f87d05c 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,3 +1,5 @@ +import { mkdirSync } from 'node:fs' +import { runProcess } from '../../src/shared/child-process/run-process' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -41,7 +43,42 @@ test.describe('Reveal active workspace button', () => { test('clears sidebar filters before revealing a hidden current workspace', async ({ orcaPage, testRepoPath - }) => { + }, testInfo) => { + const filterRepoPath = testInfo.outputPath('filter-repo') + mkdirSync(filterRepoPath, { recursive: true }) + for (const args of [ + ['init', filterRepoPath], + [ + '-C', + filterRepoPath, + '-c', + 'user.name=E2E', + '-c', + 'user.email=e2e@test.local', + 'commit', + '--allow-empty', + '-m', + 'Filter fixture' + ] + ]) { + const result = await runProcess({ program: 'git', args }) + expect(result.code, result.stderr).toBe(0) + } + const filterRepoId = await orcaPage.evaluate(async (repoPath) => { + const result = await window.api.repos.add({ path: repoPath }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, filterRepoPath) + await expect + .poll(() => + orcaPage.evaluate(async (id) => { + await window.__store!.getState().fetchRepos() + return window.__store!.getState().repos.some((repo) => repo.id === id) + }, filterRepoId) + ) + .toBe(true) await prepareSidebarForScrollTest(orcaPage) // Other specs can add worktrees to the shared repository before this test runs. @@ -86,18 +123,11 @@ test.describe('Reveal active workspace button', () => { }, targetId) await expect(targetRow).toHaveAttribute('aria-current', 'page') - await orcaPage.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - store.getState().setFilterRepoIds(['__filtered_repo__']) - }) - - // Why: the filter's row-hiding side effect is covered deterministically by - // visible-worktrees.test.ts. Asserting an empty DOM here over-specifies an - // incidental render-settle state that flakes under the shared page; the - // contract under test is that reveal clears the filter (asserted below). + // Catalog refreshes prune nonexistent IDs, so use a real repo to keep the filter applied. + await orcaPage.evaluate((repoId) => { + window.__store!.getState().setFilterRepoIds([repoId]) + }, filterRepoId) + await expect(targetRows).toHaveCount(0) await revealButton.click() await orcaPage diff --git a/tests/tools/win-crash-survival-e2e/README.md b/tests/tools/win-crash-survival-e2e/README.md index beb56cf8c67..e0e773767c4 100644 --- a/tests/tools/win-crash-survival-e2e/README.md +++ b/tests/tools/win-crash-survival-e2e/README.md @@ -13,8 +13,8 @@ orphaned and PowerShell hard-crashed with a `0xE9` "No process is on the other end of the pipe" `FailFast`. Root cause: the terminal **daemon** (which hosts the ConPTYs) died together with the main process, severing the console pipe. -The fix re-architected the daemon into a standalone, relocated -`orca-terminal-daemon.exe` (see +The fix re-architected the daemon into a standalone daemon host relocated out of +the install dir (see [`src/main/daemon/daemon-host-relocation.ts`](../../src/main/daemon/daemon-host-relocation.ts)) that is spawned **detached** and **survives main-process death**. diff --git a/tests/tools/win-crash-survival-e2e/crash-step.mjs b/tests/tools/win-crash-survival-e2e/crash-step.mjs index 43dc18f04cd..3c7d80f51e1 100644 --- a/tests/tools/win-crash-survival-e2e/crash-step.mjs +++ b/tests/tools/win-crash-survival-e2e/crash-step.mjs @@ -4,7 +4,7 @@ // daemon (which hosts the ConPTYs) died with it, severing the console pipe, and // PowerShell hard-crashed with a 0xE9 "No process is on the other end of the // pipe" FailFast. The fix relocates the daemon into a standalone, detached -// orca-terminal-daemon.exe that SURVIVES main death (src/main/daemon/ +// host process outside the install dir that SURVIVES main death (src/main/daemon/ // daemon-host-relocation.ts). This module reproduces the crash and scans for the // pwsh FailFast that must no longer occur. diff --git a/tests/tools/win-crash-survival-e2e/run.mjs b/tests/tools/win-crash-survival-e2e/run.mjs index 4f9d8b242b0..68d8a7a3bd6 100644 --- a/tests/tools/win-crash-survival-e2e/run.mjs +++ b/tests/tools/win-crash-survival-e2e/run.mjs @@ -5,7 +5,7 @@ // process is on the other end of the pipe" FailFast, because the terminal daemon // (hosting the ConPTYs) died together with the main process and severed the // console pipe. The fix relocates the daemon into a standalone, detached -// orca-terminal-daemon.exe that survives main death (src/main/daemon/ +// host process outside the install dir that survives main death (src/main/daemon/ // daemon-host-relocation.ts). win-update-e2e proves the daemon survives a // Windows UPDATE; this harness proves it survives a CRASH of the main process. //