Merge reviewed application prerequisites from main

This commit is contained in:
Neil
2026-09-06 18:57:47 -07:00
1404 changed files with 82413 additions and 14595 deletions
+13 -1
View File
@@ -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.
@@ -0,0 +1,8 @@
name: Set up WSL test runtime
description: Install a checksum-pinned Ubuntu WSL1 guest with executable Node and Git for real terminal tests.
runs:
using: composite
steps:
- name: Provision Ubuntu WSL1
shell: pwsh
run: '& "${{ github.action_path }}/setup.ps1"'
@@ -0,0 +1,32 @@
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw 'WSL test provisioning requires a Windows runner' }
$rootfs = Join-Path $env:RUNNER_TEMP 'noble-rootfs.tar.gz'
Invoke-WebRequest 'https://releases.ubuntu.com/24.04.4/ubuntu-24.04.4-wsl-amd64.wsl' -OutFile $rootfs
if ((Get-FileHash $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne '9b2f7730dc68227dd04a9f3e5eab86ad85caf556b8606ad94f1f29ff5c4fd3f5') { throw 'Ubuntu rootfs checksum mismatch' }
$distroDir = Join-Path $env:RUNNER_TEMP 'orca-wsl-ubuntu'
wsl.exe --import Ubuntu $distroDir $rootfs --version 1
if ($LASTEXITCODE -ne 0) { throw "WSL import failed: $LASTEXITCODE" }
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/true
if ($LASTEXITCODE -ne 0) { throw "WSL guest did not start: $LASTEXITCODE" }
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/apt-get update
if ($LASTEXITCODE -ne 0) { throw "WSL apt update failed: $LASTEXITCODE" }
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/apt-get install --yes git curl xz-utils
if ($LASTEXITCODE -ne 0) { throw "WSL git install failed: $LASTEXITCODE" }
$kernelMsi = Join-Path $env:RUNNER_TEMP 'wsl_update_x64.msi'
Invoke-WebRequest 'https://wslstorestorage.blob.core.windows.net/wslblob/wsl_update_x64.msi' -OutFile $kernelMsi
if ((Get-FileHash $kernelMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne '4d09c776c8d45f70a202281d18e19be1118f53159b0c217a5274a31ce18525fe') { throw 'WSL kernel installer checksum mismatch' }
$installer = Start-Process msiexec.exe -ArgumentList @('/i', $kernelMsi, '/quiet', '/norestart') -Wait -PassThru
if ($installer.ExitCode -ne 0) { throw "WSL kernel installation failed: $($installer.ExitCode)" }
wsl.exe --status
if ($LASTEXITCODE -ne 0) { throw "WSL status failed: $LASTEXITCODE" }
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/curl --fail --silent --show-error --location https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz --output /tmp/orca-node.tar.xz
if ($LASTEXITCODE -ne 0) { throw 'Node download failed' }
$nodeHash = wsl.exe --distribution Ubuntu --user root --exec /usr/bin/sha256sum /tmp/orca-node.tar.xz
if ($LASTEXITCODE -ne 0 -or -not ($nodeHash -match '^69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec')) { throw 'Node checksum mismatch' }
wsl.exe --distribution Ubuntu --user root --exec /usr/bin/tar -xJf /tmp/orca-node.tar.xz -C /usr/local --strip-components=1
if ($LASTEXITCODE -ne 0) { throw 'Node extraction failed' }
wsl.exe --distribution Ubuntu --user root --exec /usr/local/bin/node --version
if ($LASTEXITCODE -ne 0) { throw 'Node cannot execute in WSL' }
wsl.exe --list --verbose
if ($LASTEXITCODE -ne 0) { throw "WSL enumeration failed: $LASTEXITCODE" }
@@ -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
"$@"
+105 -9
View File
@@ -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:
@@ -223,6 +227,11 @@ jobs:
mapfile -t TEST_FILES < <(jq -r '.[] | select(
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
. != "tests/e2e/local-ssh-browser-routing.spec.ts" and
. != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and
. != "tests/e2e/ssh-localhost.spec.ts" and
. != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and
. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts"
)' <<<"$TEST_FILES_JSON")
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
@@ -241,7 +250,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
@@ -258,12 +267,15 @@ jobs:
needs: [build, prepare-native-cache]
# effect of one route listing a startup-readiness spec — pruning that spec would have
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
# The explicit spec clauses stay for their honest purpose: changed-e2e hands these specs to this
# lane, so editing one must still run it here.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
@@ -278,7 +290,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 +305,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 +322,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 +338,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()
@@ -344,3 +356,87 @@ jobs:
path: e2e-traces/
retention-days: 7
if-no-files-found: ignore
ssh-browser-network-route:
name: ssh browser network route
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Install SSH client
run: sudo apt-get update && sudo apt-get install -y openssh-client
- name: Run Docker SSH browser network route journeys
env:
ORCA_BACKGROUND_LAUNCH: '1'
ORCA_RUN_DOCKER_SSH_BROWSER_E2E: '1'
run: node_modules/.bin/vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts
ssh-localhost:
name: localhost SSH terminal and hooks
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-localhost.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install SSH server and headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client openssh-server python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
- name: Start isolated localhost SSH server
shell: bash
run: |
# Bare shells install Pi extensions only for an existing agent home.
mkdir -p "$HOME/.pi/agent"
fixture="$RUNNER_TEMP/orca-localhost-sshd"
mkdir -p "$fixture"
ssh-keygen -q -t ed25519 -N '' -f "$fixture/host_key"
ssh-keygen -q -t ed25519 -N '' -f "$fixture/client_key"
cat > "$fixture/sshd_config" <<EOF
Port 22222
ListenAddress 127.0.0.1
HostKey $fixture/host_key
PidFile $fixture/sshd.pid
AuthorizedKeysFile $fixture/client_key.pub
StrictModes no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AllowUsers $(id -un)
Subsystem sftp internal-sftp
EOF
sudo mkdir -p /run/sshd
sudo /usr/sbin/sshd -f "$fixture/sshd_config" -E "$fixture/sshd.log"
ssh -i "$fixture/client_key" -p 22222 -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 127.0.0.1 true || { sudo cat "$fixture/sshd.log"; exit 1; }
{
echo "ORCA_E2E_SSH_PORT=22222"
echo "ORCA_E2E_SSH_USER=$(id -un)"
echo "ORCA_E2E_SSH_IDENTITY_FILE=$fixture/client_key"
} >> "$GITHUB_ENV"
- name: Run localhost SSH terminal and hook journey
env:
SKIP_BUILD: '1'
ORCA_E2E_SSH_LOCALHOST: '1'
ORCA_FEATURE_REMOTE_AGENT_HOOKS: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
name: localhost-ssh-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -98,12 +98,17 @@ jobs:
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run --if-present test:e2e:workspace-session-golden
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm run --if-present test:e2e:windows-fresh-startup-golden
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
pnpm run --if-present test:e2e:source-control-golden
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload Playwright traces
if: failure()
+37 -1
View File
@@ -104,11 +104,47 @@ jobs:
--clobber \
android/app/build/outputs/apk/release/*.apk
else
# Why: release tags live on side branches, so GitHub's automatic
# previous-tag detection reaches back several releases; that body
# already exceeds the 125000-character API limit and grows each
# release. Pin the comparison base and cap the size.
notes_file="$RUNNER_TEMP/android-release-notes.md"
previous_tag="$(
gh release list --repo "$GITHUB_REPOSITORY" --limit 200 --json tagName --jq '.[].tagName' \
| grep '^mobile-android-v' | grep -Fxv "$tag" | sort -V | tail -1 || true
)"
if [ -n "$previous_tag" ]; then
# Why: gh writes the JSON error body to stdout on an HTTP error, so a
# non-empty file is not proof of success — gate on exit status.
if ! gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" -X POST \
-f tag_name="$tag" \
-f target_commitish="$GITHUB_SHA" \
-f previous_tag_name="$previous_tag" \
--jq .body > "$notes_file"; then
: > "$notes_file"
fi
fi
if [ ! -s "$notes_file" ]; then
printf 'Orca Mobile Android %s\n' "$tag" > "$notes_file"
fi
# Why: reuse the desktop release path's character-safe truncation so a
# multi-byte character cannot be split at the cap.
NOTES_FILE="$notes_file" \
NOTES_MODULE="$GITHUB_WORKSPACE/config/scripts/create-draft-release.mjs" \
node --input-type=module -e '
const { readFileSync, writeFileSync } = await import("node:fs")
const { pathToFileURL } = await import("node:url")
const { truncateReleaseBody } = await import(pathToFileURL(process.env.NOTES_MODULE).href)
const file = process.env.NOTES_FILE
writeFileSync(file, truncateReleaseBody(readFileSync(file, "utf8")))
'
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Orca Mobile Android $tag" \
--prerelease \
--latest=false \
--generate-notes \
--notes-file "$notes_file" \
android/app/build/outputs/apk/release/*.apk
fi
@@ -0,0 +1,74 @@
name: Packaged browser compatibility
on:
workflow_dispatch:
inputs:
ref:
description: Commit SHA or ref to validate (defaults to the selected revision)
type: string
required: false
schedule:
- cron: '20 8 * * 1'
workflow_call:
inputs:
ref:
type: string
required: false
permissions:
contents: read
jobs:
compatibility:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Install headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Download pinned old release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download v1.4.188 --repo stablyai/orca --pattern orca-ide_1.4.188_amd64.deb --dir "$RUNNER_TEMP/old-orca"
python3 - <<'PYVERIFY'
import base64,hashlib,os,pathlib,subprocess
root=pathlib.Path(os.environ['RUNNER_TEMP'])/'old-orca'
package=root/'orca-ide_1.4.188_amd64.deb'
expected='uGONFUDfinYggxcT9ac72wnnlofLQaqasDDeP0HWOSqarBwTi1Ax3khmzKUY3vUnvuYOpSCEmsH4InzLZ2vg6g=='
assert base64.b64encode(hashlib.sha512(package.read_bytes()).digest()).decode()==expected
extracted=root/'extracted'
subprocess.run(['dpkg-deb','-x',str(package),str(extracted)],check=True)
executable=extracted/'opt'/'Orca'/'orca-ide'
assert executable.is_file() and os.access(executable,os.X_OK)
with open(os.environ['GITHUB_ENV'],'a') as env: env.write('ORCA_CROSS_VERSION_PACKAGED_EXECUTABLE='+str(executable)+'\n')
print('Verified old package:',executable)
PYVERIFY
- name: Build current Electron app
env:
VITE_EXPOSE_STORE: 'true'
run: |
pnpm run build:relay
pnpm exec electron-vite build --mode e2e
pnpm run build:web-from-renderer
- name: Run both mixed-version directions
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/packaged-browser-results.json
run: >-
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm exec playwright test --config tests/playwright.config.ts
tests/e2e/packaged-mixed-version-browser-placement.spec.ts
--project=electron-headless --workers=1 --retries=0 --repeat-each=3 --reporter=list,json
- name: Require all six compatibility executions
if: always()
run: node config/scripts/verify-packaged-browser-participation.mjs test-results/packaged-browser-results.json
- uses: actions/upload-artifact@v7
if: always()
with:
name: packaged-mixed-version-audit
path: test-results/
retention-days: 3
+25
View File
@@ -45,6 +45,7 @@ jobs:
test_files: ${{ steps.e2e_filter.outputs.test_files }}
ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }}
native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }}
wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -92,6 +93,9 @@ jobs:
# trigger on IME source rather than on a spec name in some route's list.
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE" "$HEAD")"
WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)"
echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow)"
if [ "$SHOULD_RUN" = true ]; then
@@ -832,10 +836,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 +851,14 @@ 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/windows-process-table-native-addon.win32.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 +867,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
@@ -932,6 +947,16 @@ jobs:
contents: read
uses: ./.github/workflows/terminal-ime-e2e.yml
windows_wsl:
name: real WSL terminal
needs: code_paths
if: needs.code_paths.outputs.wsl_source_changed == 'true'
permissions:
contents: read
uses: ./.github/workflows/windows-wsl-e2e.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
verify:
if: always()
needs:
+133 -20
View File
@@ -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
# (`<cache>\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-<lockfile hash> 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) { '<none>' } 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) { '<none>' } 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
+203 -12
View File
@@ -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) { '<none>' } 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-<lockfile hash>`, 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()
+74
View File
@@ -0,0 +1,74 @@
name: Windows WSL terminal E2E
on:
workflow_dispatch:
inputs:
ref:
description: Commit to validate
type: string
required: false
workflow_call:
inputs:
ref:
type: string
required: false
permissions:
contents: read
concurrency:
group: windows-wsl-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
wsl-terminal:
runs-on: windows-2022
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-wsl-test-runtime
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Build relay and Electron
run: |
pnpm run build:relay
if ($LASTEXITCODE -ne 0) { throw 'Relay build failed' }
pnpm exec electron-vite build --mode e2e
if ($LASTEXITCODE -ne 0) { throw 'Electron build failed' }
- name: Exercise real WSL launch and paste
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/wsl-results.json
run: >-
pnpm exec playwright test
tests/e2e/golden-tab-bar-agent-launch.spec.ts
tests/e2e/terminal-windows-shell-paste-ownership.spec.ts
--config tests/playwright.config.ts
--project=electron-headless
--grep "WSL"
--repeat-each=3
--workers=1
--reporter=list,json
- name: Require all nine WSL executions
if: always()
run: node config/scripts/verify-wsl-e2e-participation.mjs test-results/wsl-results.json
- name: Upload WSL participation report
uses: actions/upload-artifact@v7
if: always()
with:
name: windows-wsl-participation-report
path: test-results/wsl-results.json
retention-days: 3
- uses: actions/upload-artifact@v7
if: failure()
with:
name: windows-wsl-terminal-traces
path: test-results/
retention-days: 7
+2
View File
@@ -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
+2 -1
View File
@@ -53,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.
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
- **Android:** [Download APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
@@ -4,18 +4,18 @@ Companion to [`relay-improvement-roadmap-2026-09.md`](./relay-improvement-roadma
match). This file answers three questions per item: what are the concrete steps, what can run in parallel,
and will a user notice.
## Status as of 2026-09-04 22:30Z
## Status as of 2026-09-06 16:30Z
Three buckets. "Merged" means the code is on `main` and nothing in production has changed yet. "Deployed" means users are already getting it. "Awaiting owner" means I will not touch production without a go.
**Deployed to production**
- Roll 2 relay image `4916ed67` (stablyai/orca #18959 + #18722 + #18720 flag unset): director since 2026-09-06 01:02Z, all 19 general cells by 16:29Z. Control lease 6 h ± 30 min, accept abandonment, per-cell inventory locks, pool `statement_timeout`. Record: findings doc, "Roll 2" section.
- Auth instance cap 20 + dead-family audit fix (orca-cloud #474) as revision `orca-cloud-auth-00031-tox`.
- Dynamic NAT ports in both regions (stablyai/orca #18693). Zero drops and zero proxy dial errors since.
- Nine alert policies with log metrics: 4 auth (#475), 3 relay Cloud SQL/NAT (#18693), 1 cell process-exit (#18717), all on the relay Slack channel.
**Merged, ships with the next relay cell image roll (Roll 1 carries `519f4914`; Roll 2 needs a fresh image build)**
- Per-cell inventory locks, delta counters, pool `statement_timeout` (#18722). Roll 2.
- Cells dial Cloud SQL with `--private-ip` when configured (#18720). Inert until 2.1 applies.
**Merged, not yet live**
- Cells dial Cloud SQL with `--private-ip` when configured (#18720). Deployed in Roll 2 with the flag unset; inert until 2.1 applies.
- Phone shows a clear "sign in on the desktop again" state when the desktop is signed out (#18698).
**Merged, ships with the next auth deploy**
@@ -158,9 +158,10 @@ independent. (2.2 deferred; if revived, do it after 2.1 so the new instance is p
- [ ] Production: announce a window; same steps; verify `orca_relay_runtime_metrics` controls recover to pre-cutover count.
- [ ] Update `production-cloud-sql-app-consumers` budget test and both alert policies' `database_id`.
### 2.3 Relay pool statement timeout (merged stablyai/orca #18722; ships Roll 2)
### 2.3 Relay pool statement timeout (deployed in Roll 2, 2026-09-06)
- [x] `statement_timeout` on the relay `pg.Pool` (5 s, env-configurable; schema pool untimed; `57014` retryable), below the control-renewal deadline; DDL on an untimed connection (same pattern as auth #476).
- [x] Postgres test on 55440: a held lock fails the query fast and the bounded retry takes over.
- [x] Deployed fleet-wide in Roll 2 (`4916ed67`), 2026-09-06.
### 3.1 Refresh rotation grace window (orca-cloud #478 merged 2026-09-04; deploy pending owner go)
- [ ] Fix the deploy-script env strip for `ORCA_CLOUD_REFRESH_TOKEN_TTL_DAYS` (pre-existing; found by #478).
@@ -169,14 +170,16 @@ independent. (2.2 deferred; if revived, do it after 2.1 so the new instance is p
- [x] Tests: replay inside window returns same successor; outside revokes; concurrent double-present yields one successor.
- [x] Deploy via `deploy-auth-production` (candidate → smoke → promote). Deployed 2026-09-04 23:15Z as `orca-cloud-auth-00035-gos`, cap 20 kept, 0 5xx; `successor_material` column present; sealed successors being written. (candidate → smoke → promote).
### 3.2 / 4.3 Desktop (merged stablyai/orca #18719; ships next desktop release)
### 3.2 / 4.3 Desktop (merged stablyai/orca #18719; ships next desktop release; relay side of 4.3 deployed in Roll 2)
- [x] 3.2: on refresh timeout, re-read stored session before retrying; do not re-send a token already rotated locally.
- [x] 4.3: ±10 % jitter on control lease renewal; unit test on the distribution; wire-compatible (server accepts early renewals already).
- [x] 4.3 relay side: control lease 55 min → 6 h ± 30 min (#18959), deployed in Roll 2, 2026-09-06.
### 4.1 Lock contention (partial: stablyai/orca #18722 merged; ships Roll 2)
### 4.1 Lock contention (partial: stablyai/orca #18722 deployed in Roll 2, 2026-09-06)
- [x] Replace the global `FOR UPDATE` over `relay_cells` with per-cell row locks; counters delta-only. Remaining: `assignOnce` placement lock is still global (optimistic snapshot follow-up). with per-cell row locks or `pg_advisory_xact_lock(cell)`; counters delta-only.
- [x] Postgres tests on 55440 with concurrent probes (in #18722). Staging load run still owed; `postgres_retries` per hour drops in staging load run.
- [ ] Ships in Roll 2; then 4.4 recalibrates the retries bar from a week of data.
- [x] Shipped in Roll 2 (2026-09-06). Director retries first 6 h on the new image: 13 vs 85 on the predecessor's prior 6 h.
- [ ] 4.4: recalibrate the retries bar from a week of data (after 2026-09-13).
### 4.2 Region preference
- [ ] Director: honor requested region when the preferred region has headroom, else sticky. Behind the existing flag.
@@ -989,3 +989,44 @@ 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 **success** (gate success, cell_1 rollout success, release_lease success), target `4916ed67`, rollback `85bf6799`, protocol 1, gen 148. | |
| Director go/no-go (01:02Z–07:00Z, 6 h on `00575-leq`) | **Go.** Presence confirmed (13.8k assign 200s, 410 cell + 90 director `runtime_metrics` rows/30 min). Postgres retries 13 (all `55P03` lock_timeout) vs 85 on `00570-siv` in the prior 6 h. `/v1/assign` mix 200/401/503 = 13820/5557/623 vs 14081/5256/663 before the deploy; 503s are the placement/sticky admission `Retry-After` path and cluster by source (top source 351), same shape as before. 0 `container die`, cell `sqlFailuresDelta` sum 0. The earlier all-zero read at 01:28Z was a dead gcloud credential, not a quiet fleet, and was discarded. | |
| Monitor dry-run (Roll 2 gate 1) | run 34018071984 dispatched 07:03Z at gen 148, **green** 07:18Z at `1326d6b40c`; main had moved to `b51bbf3fc6` with identical trusted code. | |
| c7 `canary-apply` (run 34018804481) | **Succeeded** 07:18–07:31Z, protocol 1, rollback `85bf6799`: gate, rollout, seal_canary, release_lease all success. Template `…-20260906072156…` on `4916ed67`; selector gen 148 → 150. Four `container die` at 07:29:16–25Z were the new container exiting during boot (`applyPostgresSchema`/`backfillRelayCellRegions` → `Connection terminated due to connection timeout`, exit 1, 2 s runtime each) while the `cloud-sql-proxy` sidecar warmed up; fifth start at 07:29:26 listening, readiness check passed 07:29:27. Same boot-order race as c13 in Roll 1 batch 1, no serving impact (cell was still drained). 139 controls by 07:34Z and climbing, `sqlFailuresDelta` 0, `sqlLatencyMsMax` ~40 ms. | |
| Monitor dry-run (Roll 2 gate 2) | run 34019568779 dispatched 07:36Z at gen 150, **green** 07:51Z at `57e34c7f03` (main `6494f2a4f0`, identical trusted code). | |
| c8 `canary-apply` (run 34020284092) | **Succeeded** 07:52–08:09Z, protocol 1, rollback `519f4914`: all jobs success. Template `…-20260906075820…` on `4916ed67`; gen 150 → 152. One boot-race `container die` at 08:05:51Z (2 s, exit 1), next start served. 101 controls by 08:10Z, `sqlFailuresDelta` 0. | |
| Monitor dry-run (Roll 2 gate 3) | run 34021119905 dispatched 08:11Z at gen 152, **green** 08:26Z at `ffbf35e0d2`. | |
| Batch 1 `batch-apply` c9,c10,c13,c14 (run 34021868303, canary 34020284092) | **Failed on cell 3 (c13); c9 and c10 succeeded.** c9 08:27–08:43Z → gen 154, c10 08:43–08:58Z → gen 156, both trust-proven and restored general. c13: isolate → gen 157, drain, template `…-20260906090225…` on `4916ed67`, one boot-race exit 09:09:50Z, readiness 09:09:51Z, transition verifier passed at migration-only 09:11:17Z (2 680 assignments, heartbeat fresh, image `4916ed67`), then `probe-relay-rehome-trust` got **409** from the director at 09:11:18Z (157 ms; c9/c10 got 200 in ~178 ms). Failsafe re-asserted migration-only at gen 157 (no change). c14 skipped, lease released. c13 is **serving on the new image but isolated**: 151 controls by 09:18Z, `sqlFailuresDelta` 0, no exits fleet-wide after 09:12Z. The probe script prints only the status, not the director's `error` body, and neither the director nor c13 logs the 409 reason; candidates are the director's source check (`runtime.ready`/`heartbeatFresh`/incarnation read ~1 s after the verifier passed) or c13's `host-drain` rejecting the probe (incarnation mismatch, shared-runtime-identity proof, or the probe host unexpectedly present). Monitor residual: the probe should print the error body. | |
| Monitor dry-run (Roll 2 gate 4) + c13 recovery | Gate run 34024459585 dispatched 09:26Z at gen 157 with c13 in migration-only. On green: `mode=rollback` for c13 with rollback digest `4916ed67` (what it already runs) and target `519f4914`, protocol 1 both ways: `ROLLBACK_RESUME=true` path, no restart, verify + trust probe + restore general. As in Roll 1 (c8 recovery), the rollback mode seals no canary authority, so c14 runs as its own `canary-apply` and the next batch is c15,c16,c19,c20 behind that. | |
| c13 recovery (run 34025225328, `mode=rollback`) | Gate 4 **green** 09:38Z. Recovery **succeeded** 09:38–09:42Z: `ROLLBACK_RESUME=true`, no restart, verifier passed at migration-only (2 679 assignments, heartbeat fresh, `4916ed67`), **trust probe passed** (`host-not-connected` ×2, idempotent, shared runtime identity rejected), activate → **gen 158**, c13 general, verifier passed again. 154 controls, `sqlFailuresDelta` 0, no exits fleet-wide since 09:12Z. The 09:11Z 409 was therefore transient: same cell, same incarnation, same image, ~30 min later the identical probe passed. Most likely the director's source check reading the runtime row within ~1 s of the verifier's pass (a `ready`/heartbeat edge), which a retry in the workflow step would absorb. Residual: retry the trust probe once on 409 and print the error body. | |
| Monitor dry-run (Roll 2 gate 5) | run 34025450523 dispatched 09:44Z at gen 158, **green** 09:59Z at `6933fd70d7` (main `d19be485d3`, identical trusted code). | |
| c14 `canary-apply` (run 34026157631) | **Succeeded** 09:59–10:20Z, protocol 1: trust-proven, gen 158 → 160, canary authority sealed. No boot exits, 102 controls by 10:22Z, fleet `sqlFailuresDelta` 0 over 30 min. | |
| Monitor dry-run (Roll 2 gate 6) | run 34027238190 dispatched 10:23Z at gen 160, **green** 10:38Z at `ec64df335e` (main `adcc30be3b`, identical trusted code). | |
| Batch 2 `batch-apply` c15,c16,c19,c20 (run 34027985784, canary 34026157631) | **All four succeeded** 10:38–11:31Z, protocol 1, four trust proofs, gen 160 → 168. Boot-race exits only: 3 at 10:50Z (c16) and 5 at 11:02Z (c19), all 2–4 s, exit 1, next start served. Controls at 11:32Z: c15 160, c16 164, c19 164, c20 87 (still refilling). Fleet `sqlFailuresDelta` 1 over 30 min. | |
| Monitor dry-run (Roll 2 gate 7) | run 34030557166 dispatched 11:33Z at gen 168, **green** 11:48Z at `adcc30be3b`. | |
| c22 `canary-apply` (run 34031304526) | **Succeeded** 11:48–12:02Z, protocol 1, trust-proven, gen 168 → 170, canary authority sealed. No boot exits, 134 controls by 12:03Z. One correlated 1 s lock-timeout blip at 11:35:17–27Z (c10, c13, c19, c25, c28: one `sqlFailuresDelta` each, `sqlLatencyMsMax` ≈1 000 ms) spanning old and new images, the known lock-wait shape, not roll-related. Director retries 4 in the last hour. | |
| Monitor dry-run (Roll 2 gate 8) | run 34032011250 dispatched 12:05Z at gen 170, **green** 12:20Z at `adcc30be3b`. | |
| Batch 3 `batch-apply` c23,c24,c25,c26 (run 34032799574, canary 34031304526) | **Failed on cell 4 (c26); c23, c24, c25 succeeded** (12:20–13:11Z, gen 170 → 176, three trust proofs). c26: isolate → gen 177, drain, template `…-20260906131159…` on `4916ed67`, one boot-race exit 13:19:17Z, readiness 13:19:19Z, transition verifier passed at migration-only 13:20:42Z (2 604 assignments, heartbeat fresh, `4916ed67`), then the very next call, `admin_post target-runtime` to `c26.relay.onorca.dev/v1/admin/runtime-status`, got **503 `unconditional drop overload`** (27-byte body) and the step failed. That string is not in the relay codebase and c26 logged nothing at 13:20:42Z (readiness at 13:19:19Z, metrics steady), so it is a front-end/LB shed on one request; curl's `--retry 3` logged no retry attempt. Failsafe re-asserted migration-only at gen 177 (no change). c26 is serving on the new image but isolated: 166 controls by 13:25Z and climbing, `sqlFailuresDelta` 0. Residual: the post-apply `admin_post` should retry on 503 (the pre-apply one already tolerates a transient 5xx by comment). | |
| c26 recovery (run 34036875433, `mode=rollback`) | Gate 9 (run 34036059275) **green** 13:41Z at gen 177 with c26 migration-only. Recovery **succeeded** 13:42–13:46Z: `ROLLBACK_RESUME=true`, no restart, verifier + trust probe passed, activate → **gen 178**, c26 general. 176 controls, `sqlFailuresDelta` 0, no exits since 13:25Z. **All 16 US general cells are on `4916ed67`.** | |
| Monitor dry-run (Roll 2 gate 10) | run 34037169783 dispatched 13:48Z at gen 178, **green** 14:03Z at `f952f1ac96`. | |
| c27 `canary-apply` (run 34037973681, Asia, protocol 0) | **Succeeded** 14:03–14:19Z, gen 178 → 180, canary authority sealed (unused; Asia cells roll as single canaries). Template on `4916ed67`, no boot exits, 51 controls by 14:20Z (Asia cell, refilling), `sqlFailuresDelta` 0, `sqlLatencyMsMax` ~1 040 ms (cross-region baseline, c28 on the old image reads ~1 055 ms). Fleet `sqlFailuresDelta` 5 over 30 min: c28 ×3 (~1.17 s), c8 and c9 ×1 (1 s bar), the known lock-wait singles. | |
| Monitor dry-run (Roll 2 gate 11) | run 34038869552 dispatched 14:21Z at gen 180, **green** 14:36Z at `f952f1ac96`. | |
| c28 `canary-apply` (run 34039710735, Asia, protocol 0) | **Succeeded** 14:36–14:53Z, gen 180 → 182. Template on `4916ed67`, no boot exits, 37 controls by 14:55Z (refilling), `sqlFailuresDelta` 0, `sqlLatencyMsMax` ~1 045 ms. Fleet `sqlFailuresDelta` 3 over 30 min. | |
| Monitor dry-run (Roll 2 gate 12) | run 34040698172 dispatched 14:56Z at gen 182, **green** 15:12Z at `1d2e00819f`. | |
| c29 `canary-apply` (run 34041558414, Asia, protocol 0) | **Succeeded** 15:12–15:28Z, gen 182 → 184. No boot exits, 55 controls by 15:29Z. | |
| Census 15:29Z | MIG templates: 18 of 19 general cells on `4916ed67`; **c21 still on `519f4914`**. When c13's recovery re-sealed the canary at c14, batch 2 took c15,c16,c19,c20 and c21 dropped out of the plan's wave (`c15 canary + c16,c19,c20,c21`). Fleet 23 cells, 2 971 controls. Roll 2 exits since 07:00Z: 20, all boot-race (<10 s), 0 serving. Director retries 5 in the last hour. c21 rolls next as a single canary. | |
| Monitor dry-run (Roll 2 gate 13) | run 34042460176 dispatched 15:30Z at gen 184, **green** 15:45Z at `3631f886a7`. | |
| c21 `canary-apply` (run 34043296422, protocol 1) | **Failed at the same post-apply step as c26.** Isolate → gen 185, drain, template `…-20260906155550…` on `4916ed67`, verifier passed at migration-only 16:04:46Z (2 607 assignments, heartbeat fresh, `4916ed67`), then `admin_post target-runtime` to c21 got **503 `unconditional drop overload`** again (27-byte body, ~160 ms after the verifier's own successful read). Failsafe held migration-only at gen 185. c21 serving on the new image, isolated, 111 controls by 16:07Z. Second occurrence in ~3 h on two different cells, both ~1.3 min after readiness: consistent with an edge shed on the first admin request after the LB backend flips healthy. The step needs the same transient-5xx tolerance as the pre-apply read. | |
| Monitor dry-run (Roll 2 gate 14) + c21 recovery | Gate run 34044440616 dispatched 16:08Z at gen 185 with c21 migration-only. On green: `mode=rollback` resume for c21 (rollback digest `4916ed67`, protocol 1). | |
| c21 recovery (run 34045296151, `mode=rollback`) | Gate 14 **green** 16:23Z. Recovery **succeeded** 16:24–16:28Z: no restart, verifier + trust probe passed, activate → **gen 186**, c21 general. 164 controls, `sqlFailuresDelta` 0. | |
| **Roll 2 complete** 16:29Z | **All 19 general cells on `4916ed67`** (c7–c10, c13–c16, c19–c29); existing-only c1–c6, c11, c12 and migration-only c17, c18 untouched. Selector gen 148 → 186. Fleet 23 cells, 2 927 controls. Container exits 07:00–16:29Z: 20, every one a boot-race exit (<10 s, `cloud-sql-proxy` sidecar not yet listening), **0 serving-process exits**. Director on `00575-leq` (`4916ed67`) since 01:02Z: Postgres retries 0 in the last hour (13 over the first 6 h vs 85 on the predecessor), 5xx in the last hour 104 `/v1/assign` 503s (admission `Retry-After` path, at the pre-roll rate). Three waves needed the no-restart `mode=rollback` resume (c13: transient trust-probe 409; c26 and c21: post-apply `runtime-status` 503 `unconditional drop overload`), each recovered in ~4 min with no drain. 14 monitor gates, 14 green, 0 freezes. | |
+5
View File
@@ -133,6 +133,11 @@ Record every gate and wave in the findings doc as in Roll 1.
dropped.
- **Monitor residuals** already in the checklist: `probeEndpointHealth` retry decision still uses the
flat 2 000 ms bar; operator protocol unbound for Asia; `probe-relay-rehome-trust` regex.
- **Same-cap job residuals found in Roll 2** (three of eleven mutating runs needed the resume path):
the post-apply `admin_post target-runtime` read has no transient-5xx tolerance and failed twice on a
one-request 503 `unconditional drop overload` from the edge ~80 s after readiness (c26, c21); and
`probe-relay-rehome-trust` prints only the status on a 409, so the transient c13 failure left no
reason on record. Retry both once and print the error body.
- Update the checklist status header; tick 2.3, 4.1, 4.3 relay-side as deployed.
## Deferred, owner decision required
+12 -3
View File
@@ -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'),
+35 -9
View File
@@ -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"
+1
View File
@@ -28,6 +28,7 @@
"app-store-performance/require-selector": "warn",
"app-store-performance/no-identity-selector": "warn",
"app-store-performance/no-fresh-selector-result": "warn",
"app-store-performance/no-nested-fresh-under-shallow": "warn",
"quadratic-buffer-concat/no-loop-carried-concat": "warn",
"sort-comparator-performance/no-repeated-collator": "warn"
},
+204 -68
View File
@@ -8,6 +8,19 @@ const ALLOCATING_METHODS = new Set([
'toSpliced',
'with'
])
const ALLOCATING_OBJECT_STATICS = new Set([
'assign',
'create',
'entries',
'fromEntries',
'keys',
'values'
])
const FUNCTION_NODES = new Set([
'ArrowFunctionExpression',
'FunctionDeclaration',
'FunctionExpression'
])
function identifierName(node) {
return node?.type === 'Identifier' ? node.name : null
@@ -25,8 +38,12 @@ function propertyName(node) {
: null
}
function functionNode(node) {
return FUNCTION_NODES.has(node?.type) ? node : null
}
function returnedExpressions(selector) {
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
if (!functionNode(selector)) {
return []
}
if (selector.body.type !== 'BlockStatement') {
@@ -37,10 +54,7 @@ function returnedExpressions(selector) {
if (!node || typeof node !== 'object') {
return
}
if (
node !== selector.body &&
['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(node.type)
) {
if (node !== selector.body && FUNCTION_NODES.has(node.type)) {
return
}
if (node.type === 'ReturnStatement') {
@@ -76,10 +90,7 @@ function unwrapShallowSelector(selector, shallowHooks) {
}
function isIdentitySelector(selector) {
if (selector?.type !== 'ArrowFunctionExpression' && selector?.type !== 'FunctionExpression') {
return false
}
const parameter = selector.params[0]
const parameter = functionNode(selector)?.params[0]
if (parameter?.type !== 'Identifier') {
return false
}
@@ -88,14 +99,23 @@ function isIdentitySelector(selector) {
)
}
function isAllocatingExpression(expression) {
if (expression?.type === 'ConditionalExpression') {
return (
isAllocatingExpression(expression.consequent) || isAllocatingExpression(expression.alternate)
)
}
if (expression?.type === 'LogicalExpression') {
return isAllocatingExpression(expression.left) || isAllocatingExpression(expression.right)
/**
* `everyBranch` decides how a conditional counts. An inline selector is flagged
* when ANY branch allocates; a helper the selector delegates to must allocate on
* EVERY branch, so the `cache.get(k) ?? build(state)` identity-caching shape is
* not a false positive.
*/
function allocates(expression, everyBranch) {
const branches =
expression?.type === 'ConditionalExpression'
? [expression.consequent, expression.alternate]
: expression?.type === 'LogicalExpression'
? [expression.left, expression.right]
: null
if (branches) {
return everyBranch
? branches.every((branch) => allocates(branch, true))
: branches.some((branch) => allocates(branch, false))
}
if (
expression?.type === 'ArrayExpression' ||
@@ -107,44 +127,104 @@ function isAllocatingExpression(expression) {
if (expression?.type !== 'CallExpression') {
return false
}
const method = propertyName(expression.callee)
if (method && ALLOCATING_METHODS.has(method)) {
return true
}
const callee = expression.callee
const method = propertyName(callee)
return (
callee.type === 'MemberExpression' &&
identifierName(callee.object) === 'Object' &&
['assign', 'create', 'entries', 'fromEntries', 'keys', 'values'].includes(propertyName(callee))
ALLOCATING_METHODS.has(method) ||
(identifierName(callee.object) === 'Object' && ALLOCATING_OBJECT_STATICS.has(method))
)
}
function importedLocalName(specifier, importedName) {
if (specifier.type !== 'ImportSpecifier' || identifierName(specifier.imported) !== importedName) {
return null
function isAllocatingExpression(expression) {
return allocates(expression, false)
}
// Project-local zustand hooks follow the use<Name>Store convention; React's
// useSyncExternalStore matches that shape but is not a store subscription.
const STORE_HOOK_NAME = /^use[A-Z][A-Za-z0-9]*Store$/
const NON_STORE_HOOKS = new Set(['useSyncExternalStore'])
function isLocalModuleSource(source) {
return typeof source === 'string' && (source.startsWith('.') || source.startsWith('@/'))
}
/** Module scope only: a component-local helper must not shadow a same-named import. */
function isModuleScope(node) {
const parent = node.parent
return (
parent?.type === 'Program' ||
(parent?.type === 'ExportNamedDeclaration' && parent.parent?.type === 'Program')
)
}
/** Records module-scope `const selectX = (state) => ...` so identifier selectors resolve. */
function recordNamedSelector(node, state) {
if (!isModuleScope(node)) {
return
}
return identifierName(specifier.local)
const declared =
node.type === 'FunctionDeclaration'
? [[node.id, node]]
: node.declarations.map((declarator) => [declarator.id, declarator.init])
for (const [id, initializer] of declared) {
const name = identifierName(id)
if (name && functionNode(initializer)) {
state.namedSelectors.set(name, initializer)
}
}
}
/** Inline function, or a module-scope selector referenced by name. */
function resolveSelector(argument, state) {
return functionNode(argument) ?? state.namedSelectors.get(identifierName(argument)) ?? null
}
/**
* One hop: a selector that delegates to a module-scope helper is the idiomatic
* shape here, and neither the inline-body check nor a reviewer reading the call
* site can see what that helper returns. An unresolvable helper is left alone.
*/
function expandThroughNamedHelper(expression, state) {
const helper =
expression?.type === 'CallExpression'
? state.namedSelectors.get(identifierName(expression.callee))
: undefined
const returned = helper ? returnedExpressions(helper) : []
return returned.length > 0 && returned.every((entry) => allocates(entry, true))
? returned
: [expression]
}
function createRuleState() {
return {
appStoreHooks: new Set(),
shallowHooks: new Set()
shallowHooks: new Set(),
namedSelectors: new Map(),
deferredCalls: []
}
}
function recordImports(node, state) {
if (node.source?.value === 'zustand/react/shallow') {
for (const specifier of node.specifiers) {
const localName = importedLocalName(specifier, 'useShallow')
if (localName) {
state.shallowHooks.add(localName)
}
}
}
const source = node.source?.value
for (const specifier of node.specifiers) {
const localName = importedLocalName(specifier, 'useAppStore')
if (localName) {
if (specifier.type !== 'ImportSpecifier') {
continue
}
const imported = identifierName(specifier.imported)
const localName = identifierName(specifier.local)
if (!imported || !localName) {
continue
}
if (source === 'zustand/react/shallow' && imported === 'useShallow') {
state.shallowHooks.add(localName)
}
// useAppStore is the app store wherever it is re-exported from; sibling
// stores are trusted by naming convention only when they come from this codebase.
if (
STORE_HOOK_NAME.test(imported) &&
!NON_STORE_HOOKS.has(imported) &&
(imported === 'useAppStore' || isLocalModuleSource(source))
) {
state.appStoreHooks.add(localName)
}
}
@@ -176,52 +256,107 @@ function requireSelectorRule() {
}
}
function noIdentitySelectorRule() {
/**
* Selector arguments are collected during traversal and judged at Program:exit so a
* selector hoisted below its call site still resolves.
*/
function deferredSelectorRule(inspect) {
const state = createRuleState()
return {
ImportDeclaration(node) {
recordImports(node, state)
},
FunctionDeclaration(node) {
recordNamedSelector(node, state)
},
VariableDeclaration(node) {
recordNamedSelector(node, state)
},
CallExpression(node) {
if (!isAppStoreCall(node, state)) {
return
if (isAppStoreCall(node, state)) {
state.deferredCalls.push(node)
}
const { selector } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
if (isIdentitySelector(selector)) {
this.report({
node: selector,
message:
'Select the smallest required fields instead of subscribing to the entire app store.'
},
'Program:exit'() {
for (const node of state.deferredCalls) {
const { selector: argument, shallow } = unwrapShallowSelector(
node.arguments[0],
state.shallowHooks
)
const report = inspect({
selector: resolveSelector(argument, state),
shallow,
state
})
if (report) {
this.report(report)
}
}
}
}
}
function noIdentitySelectorRule() {
return deferredSelectorRule(({ selector }) =>
isIdentitySelector(selector)
? {
node: selector,
message:
'Select the smallest required fields instead of subscribing to the entire app store.'
}
: null
)
}
function noFreshSelectorResultRule() {
const state = createRuleState()
return {
ImportDeclaration(node) {
recordImports(node, state)
},
CallExpression(node) {
if (!isAppStoreCall(node, state)) {
return
}
const { selector, shallow } = unwrapShallowSelector(node.arguments[0], state.shallowHooks)
if (shallow) {
return
}
const freshResult = returnedExpressions(selector).find(isAllocatingExpression)
if (freshResult) {
this.report({
return deferredSelectorRule(({ selector, shallow, state }) => {
if (shallow || !selector) {
return null
}
const freshResult = returnedExpressions(selector)
.flatMap((expression) => expandThroughNamedHelper(expression, state))
.find(isAllocatingExpression)
return freshResult
? {
node: freshResult,
message:
'This selector returns a fresh reference on every store write; select a stable field, cache the result, or use useShallow.'
})
}
}
}
: null
})
}
/** useShallow compares one level deep, so a fresh reference nested inside its result never matches. */
function nestedFreshValues(expression) {
if (expression?.type === 'ObjectExpression') {
return expression.properties
.map((property) => (property.type === 'Property' ? property.value : null))
.filter(Boolean)
}
if (expression?.type === 'ArrayExpression') {
return expression.elements.filter(Boolean)
}
return []
}
function noNestedFreshUnderShallowRule() {
return deferredSelectorRule(({ selector, shallow, state }) => {
if (!shallow || !selector) {
return null
}
const nestedFresh = returnedExpressions(selector)
.flatMap((expression) => expandThroughNamedHelper(expression, state))
.flatMap(nestedFreshValues)
.flatMap((expression) => expandThroughNamedHelper(expression, state))
.find(isAllocatingExpression)
return nestedFresh
? {
node: nestedFresh,
message:
'useShallow compares only one level deep, so this nested fresh reference changes on every store write and defeats the memo; project the primitives the component actually renders.'
}
: null
})
}
function bindContext(createVisitors) {
@@ -239,6 +374,7 @@ export default {
rules: {
'require-selector': { create: bindContext(requireSelectorRule) },
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) },
'no-nested-fresh-under-shallow': { create: bindContext(noNestedFreshUnderShallowRule) }
}
}
+31 -8
View File
@@ -14,6 +14,7 @@ const projectDir = resolve(__dirname, '..')
const requireFromProject = createRequire(join(projectDir, 'package.json'))
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'@anthropic-ai/claude-agent-sdk',
'@electron-toolkit/utils',
'@linear/sdk',
'@parcel/watcher',
@@ -56,6 +57,11 @@ const ELECTRON_ARCHITECTURE_BY_ENUM = {
4: 'universal'
}
const PACKAGED_NATIVE_ARCHITECTURES = new Set(['ia32', 'x64', 'arm', 'arm64'])
const PACKAGED_MAIN_REQUIRED_FILES = [
'out/main/index.js',
'out/main/agent-hooks/managed-agent-hook-controls.js'
]
const PACKAGED_MAIN_SOURCE_RE = /^out\/main\/.+\.js$/
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
const JS_SOURCE_MAP_ARTIFACT_RE = /\.(?:c|m)?js\.map$/
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
@@ -223,22 +229,39 @@ function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/a
return
}
const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js']
const entries = asar.listPackage(asarPath)
const missing = new Set()
for (const file of mainFiles) {
const entry = findAsarEntry(entries, file)
if (!entry) {
for (const file of PACKAGED_MAIN_REQUIRED_FILES) {
if (!findAsarEntry(entries, file)) {
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
}
}
const missing = new Set()
// Why every emitted main file rather than the entry points alone: rolldown hoists
// modules shared by two entries into out/main/chunks, so an entry's own bare imports
// move out from under a fixed file list and silently stop being checked.
for (const entry of entries) {
if (!PACKAGED_MAIN_SOURCE_RE.test(normalizeAsarEntryPath(entry))) {
continue
}
// Why: @electron/asar lists entries with host separators; Windows returns
// backslashes, and extractFile expects that same host-style path.
const internalPath = entry.replace(/^[\\/]+/, '')
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) {
const specifier = match[1]
// Why the lookbehind: Orca has its own registry methods named `require`, so a
// minified `registry.require('some-id')` must not read as a bare specifier.
// Why it readmits `...`: a dot that ends a spread is not member access, and
// the two error directions are not symmetric -- a false positive fails the
// release build loudly, a false negative is this guard going blind.
// Known limit: a specifier inside an embedded source string counts too, and
// ssh-relay-deploy's remote probe names node-pty that way. A remote-only
// dependency added to that script would fail desktop packaging here; telling
// the two apart needs a parser, not a wider pattern.
for (const match of source.matchAll(
/(?:(?<![.\w])|(?<=\.\.\.))(?:require|import)\s*\(\s*(["'`])([^"'`$]+)\1\s*\)/g
)) {
const specifier = match[2]
if (!isPackagedExternalSpecifier(specifier)) {
continue
}
@@ -1,5 +1,5 @@
diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304cd1fea14 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -3,7 +3,6 @@
@@ -10,7 +10,8 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7
],
"conditions": [
['OS=="win"', {
@@ -15,12 +14,11 @@
@@ -14,13 +13,12 @@
"src/process_worker.cc",
"src/process_commandline.cc"
],
- "include_dirs": [],
@@ -26,11 +27,111 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7
"AdditionalOptions": [
"/guard:cf",
"/sdl",
diff --git a/lib/index.js b/lib/index.js
index 9747a7402600cd252859144d32580ed45c8c93f7..001e81fa8bc89091971d06aaf9d051ba20906615 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -7,11 +7,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0;
const util_1 = require("util");
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
+exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;
var ProcessDataFlag;
(function (ProcessDataFlag) {
ProcessDataFlag[ProcessDataFlag["None"] = 0] = "None";
ProcessDataFlag[ProcessDataFlag["Memory"] = 1] = "Memory";
ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";
+ ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime";
})(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {}));
// requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls
// to this cannot be done at the same time.
@@ -66,11 +68,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) {
// • the properties are inlined/splatted
// • the 'ppid' field is omitted
// • the depth of the tree is limited by `maxDepth`
- const buildNode = ({ info: { pid, name, memory, commandLine }, children }, depth) => ({
+ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }, depth) => ({
pid,
name,
memory,
commandLine,
+ creationTimeMs,
children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [],
});
return buildNode(root, maxDepth);
diff --git a/lib/index.ts b/lib/index.ts
index f9aa005d9ced9e42885b8a976de5eb5bd61899ee..1b509af0b9065918bcb5cb75f2d7f23821d4a56a 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -6,12 +6,15 @@
import { promisify } from 'util';
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
+/** The flag bits this compiled addon reports; undefined off win32. */
+export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags;
import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree';
export enum ProcessDataFlag {
None = 0,
Memory = 1,
- CommandLine = 2
+ CommandLine = 2,
+ CreationTime = 4
}
type RequestCallback = (processList: IProcessInfo[]) => void;
@@ -81,11 +84,12 @@ export function buildProcessTree(rootPid: number, processList: Iterable<IProcess
// • the properties are inlined/splatted
// • the 'ppid' field is omitted
// • the depth of the tree is limited by `maxDepth`
- const buildNode = ({ info: { pid, name, memory, commandLine }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
+ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
pid,
name,
memory,
commandLine,
+ creationTimeMs,
children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [],
});
diff --git a/src/addon.cc b/src/addon.cc
index 9214aff281251e797a70ecb9f6e0b52932a0503f..722edd42ddb4740296bfc47582a181bd6d00c464 100644
--- a/src/addon.cc
+++ b/src/addon.cc
@@ -53,6 +53,10 @@ void GetProcessCpuUsage(const Napi::CallbackInfo& args) {
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("getProcessList", Napi::Function::New(env, GetProcessList));
exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage));
+ // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is
+ // patched source and says nothing about what the .node was compiled from.
+ exports.Set("supportedProcessDataFlags",
+ Napi::Number::New(env, MEMORY | COMMANDLINE | CREATIONTIME));
return exports;
}
diff --git a/src/process.cc b/src/process.cc
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
index 3eea92077c4d1d433119361d5c432881859131e9..22a47421da919c76e2194280974d39c2287b098d 100644
--- a/src/process.cc
+++ b/src/process.cc
@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
@@ -21,7 +21,8 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
if (Process32First(snapshot_handle, &process_entry)) {
do {
if (process_entry.th32ProcessID != 0) {
- ProcessInfo pinfo;
+ // Value-initialize: `memory` is otherwise stack garbage when the flag is unset.
+ ProcessInfo pinfo{};
pinfo.pid = process_entry.th32ProcessID;
pinfo.ppid = process_entry.th32ParentProcessID;
@@ -33,23 +34,51 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
GetProcessCommandLine(pinfo);
}
+ if (CREATIONTIME & process_data_flags) {
+ GetProcessCreationTime(pinfo);
+ }
+
strcpy(pinfo.name, process_entry.szExeFile);
process_info.push_back(std::move(pinfo));
process_count++;
}
@@ -39,3 +140,301 @@ index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5
}
CloseHandle(snapshot_handle);
return process_count;
}
+void GetProcessCreationTime(ProcessInfo& process_info) {
+ HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid);
+ if (hProcess == NULL) {
+ return;
+ }
+
+ FILETIME creationTime, exitTime, kernelTime, userTime;
+ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) {
+ ULARGE_INTEGER timestamp;
+ timestamp.LowPart = creationTime.dwLowDateTime;
+ timestamp.HighPart = creationTime.dwHighDateTime;
+ constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL;
+ constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL;
+ if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) {
+ process_info.creationTimeMs =
+ (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND;
+ }
+ }
+
+ CloseHandle(hProcess);
+}
+
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);
+ // 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;
@@ -81,7 +110,8 @@ 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);
+ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION.
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (hProcess == NULL) {
return;
diff --git a/src/process.h b/src/process.h
index 82f8e4bcfa742551e5d874a7632736a7611d7aa7..78d1d2c3b2360ed06fd624b4cb2f5042510f7a77 100644
--- a/src/process.h
+++ b/src/process.h
@@ -22,18 +22,22 @@ struct ProcessInfo {
DWORD ppid;
DWORD memory; // Reported in bytes
std::string commandLine;
+ ULONGLONG creationTimeMs;
};
enum ProcessDataFlags {
NONE = 0,
MEMORY = 1,
- COMMANDLINE = 2
+ COMMANDLINE = 2,
+ CREATIONTIME = 4
};
uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info, DWORD flags);
void GetProcessMemoryUsage(ProcessInfo& process_info);
+void GetProcessCreationTime(ProcessInfo& process_info);
+
void GetCpuUsage(Cpu& cpu_info, bool first_run);
#endif // SRC_PROCESS_H_
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
@@ -7,61 +7,119 @@
#include "process_commandline.h"
#include <windows.h>
#include <winternl.h>
-#include <iostream>
+#include <vector>
-bool GetProcessCommandLine(ProcessInfo& process_info) {
- HINSTANCE ntdll = GetModuleHandleW(L"ntdll.dll");
+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<NTSTATUS>(0xC0000004L);
+const NTSTATUS kStatusBufferTooSmall = static_cast<NTSTATUS>(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<NtQueryInformationProcessFn>(
+ 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<int>(wide_length);
+ int charcount = WideCharToMultiByte(CP_UTF8, 0, data, length, NULL, 0, NULL, NULL);
+ if (!charcount) {
return false;
}
+ process_info.commandLine.resize(static_cast<size_t>(charcount));
+ WideCharToMultiByte(CP_UTF8, 0, data, length, &process_info.commandLine[0], charcount, NULL,
+ NULL);
+ return true;
+}
+
+} // namespace
- decltype(NtQueryInformationProcess)* nt_query_information_process =
- reinterpret_cast<decltype(NtQueryInformationProcess)*>(
- GetProcAddress(ntdll, "NtQueryInformationProcess"));
+bool GetProcessCommandLine(ProcessInfo& process_info) {
+ NtQueryInformationProcessFn query = NtQueryInformationProcessEntry();
+ if (!query) {
+ return false;
+ }
- if (!nt_query_information_process) {
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_info.pid);
+ if (process == NULL) {
return false;
}
- PROCESS_BASIC_INFORMATION pbi{};
- PEB peb = {NULL};
- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL};
+ 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;
+ }
- // Get process handle
- DWORD pid = process_info.pid;
- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
- if (hProcess == INVALID_HANDLE_VALUE) {
+ std::vector<unsigned char> buffer(size);
+ status = query(process, kProcessCommandLineInformation, &buffer[0], size, &size);
+ CloseHandle(process);
+ if (!NT_SUCCESS(status)) {
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<int>(buffer.length());
- int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
- NULL, 0, NULL, NULL);
- if (charcount) {
- process_info.commandLine.resize(static_cast<size_t>(charcount));
- WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
- &process_info.commandLine[0], charcount,
- NULL, NULL);
- }
- CloseHandle(hProcess);
- return true;
- }
- }
- }
- }
+ // 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<const UNICODE_STRING*>(&buffer[0]);
+ const unsigned char* begin = &buffer[0];
+ const unsigned char* end = begin + buffer.size();
+ const unsigned char* chars = reinterpret_cast<const unsigned char*>(command_line->Buffer);
+ if (chars == nullptr || chars < begin + sizeof(UNICODE_STRING) || chars > end ||
+ command_line->Length > static_cast<ULONG>(end - chars)) {
+ return false;
}
- CloseHandle(hProcess);
- 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/src/process_worker.cc b/src/process_worker.cc
index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529e6a2fd7a 100644
--- a/src/process_worker.cc
+++ b/src/process_worker.cc
@@ -43,6 +43,11 @@ void GetProcessesWorker::OnOK() {
Napi::String::New(env, pinfo.commandLine));
}
+ if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) {
+ object.Set("creationTimeMs",
+ Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs)));
+ }
+
result.Set(i, object);
}
diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts
index 08bdac2fdc5ead6f0fcfb5ee5a021e2298c7d523..458981566fc45c0084badff566b1e3791ec1b629 100644
--- a/typings/windows-process-tree.d.ts
+++ b/typings/windows-process-tree.d.ts
@@ -7,9 +7,17 @@ declare module '@vscode/windows-process-tree' {
export enum ProcessDataFlag {
None = 0,
Memory = 1,
- CommandLine = 2
+ CommandLine = 2,
+ CreationTime = 4
}
+ /**
+ * The flag bits the compiled addon actually understands, or undefined off
+ * win32. `ProcessDataFlag` above is source; this is what the binary reports,
+ * so it is the only way to tell a patched build from a stale prebuilt.
+ */
+ export const supportedProcessDataFlags: number | undefined;
+
export interface IProcessInfo {
pid: number;
ppid: number;
@@ -24,6 +32,9 @@ declare module '@vscode/windows-process-tree' {
* The string returned is at most 512 chars, strings exceeding this length are truncated.
*/
commandLine?: string;
+
+ /** Process creation time in Unix milliseconds. */
+ creationTimeMs?: number;
}
export interface IProcessCpuInfo extends IProcessInfo {
@@ -35,6 +46,7 @@ declare module '@vscode/windows-process-tree' {
name: string;
memory?: number;
commandLine?: string;
+ creationTimeMs?: number;
children: IProcessTreeNode[];
}
File diff suppressed because one or more lines are too long
@@ -12,7 +12,8 @@ function lintSource(source) {
rules: {
'app-store-performance/require-selector': 'warn',
'app-store-performance/no-identity-selector': 'warn',
'app-store-performance/no-fresh-selector-result': 'warn'
'app-store-performance/no-fresh-selector-result': 'warn',
'app-store-performance/no-nested-fresh-under-shallow': 'warn'
}
})
}
@@ -52,4 +53,92 @@ describe('app store performance Oxlint plugin', () => {
expect(diagnostics).toEqual([])
})
it('resolves selectors referenced by name, including ones hoisted below the call', () => {
const diagnostics = lintSource(`
import { useAppStore } from '@/store'
const EarlyFresh = () => useAppStore(selectFreshRows)
const selectFreshRows = (state) => state.rows.filter(Boolean)
const Stable = () => useAppStore(selectActiveId)
const selectActiveId = (state) => state.activeId
`)
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'app-store-performance(no-fresh-selector-result)'
])
})
it('does not let a component-local helper resolve a same-named imported selector', () => {
const diagnostics = lintSource(`
import { useAppStore } from '@/store'
import { selectRows } from './selectors'
const Other = () => {
const selectRows = (state) => state.rows.map((row) => row.id)
return selectRows
}
const Imported = () => useAppStore(selectRows)
`)
expect(diagnostics).toEqual([])
})
it('covers sibling store hooks but not useSyncExternalStore', () => {
const diagnostics = lintSource(`
import { usePluginPanelsStore } from '@/store/plugin-panels'
import { useSyncExternalStore } from 'react'
const WholePanels = () => usePluginPanelsStore()
const FreshPanels = () => usePluginPanelsStore((state) => ({ open: state.open }))
const External = () => useSyncExternalStore(subscribe, () => ({ open: true }))
`)
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'app-store-performance(require-selector)',
'app-store-performance(no-fresh-selector-result)'
])
})
it('reports fresh references nested inside a useShallow projection', () => {
const diagnostics = lintSource(`
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
const NestedObject = () => useAppStore(useShallow((state) => ({ ids: state.rows.map((row) => row.id) })))
const NestedArray = () => useAppStore(useShallow((state) => [state.activeId, state.rows.filter(Boolean)]))
const Flat = () => useAppStore(useShallow((state) => ({ activeId: state.activeId, rows: state.rows })))
`)
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'app-store-performance(no-nested-fresh-under-shallow)',
'app-store-performance(no-nested-fresh-under-shallow)'
])
})
it('follows a selector one hop into a module-scope helper', () => {
const diagnostics = lintSource(`
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
const buildRows = (state) => state.rows.map((row) => row.id)
const Delegating = () => useAppStore((state) => buildRows(state))
const NestedDelegating = () => useAppStore(useShallow((state) => ({ ids: buildRows(state) })))
`)
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'app-store-performance(no-fresh-selector-result)',
'app-store-performance(no-nested-fresh-under-shallow)'
])
})
it('does not flag a helper that returns a cached reference on some branch', () => {
const diagnostics = lintSource(`
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
// The identity-caching shape: fresh only on a miss, cached otherwise.
const selectCachedRows = (state) => cache.get(state.key) ?? state.rows.filter(Boolean)
const Cached = () => useAppStore((state) => selectCachedRows(state))
const CachedNested = () => useAppStore(useShallow((state) => ({ rows: selectCachedRows(state) })))
// An unknown helper cannot be resolved, so it must not be guessed at.
const External = () => useAppStore((state) => externalBuild(state))
`)
expect(diagnostics).toEqual([])
})
})
@@ -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))
@@ -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 <dir> --composite false --incremental false.
// Run: node config/scripts/benchmark-cli-error-imports.mjs <before-dir> <after-dir>
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
)
)
@@ -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 <base-ref>
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))
@@ -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
)
)
@@ -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)
}
+122
View File
@@ -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 })
}
@@ -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 })
}
@@ -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 })
}
@@ -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,217 @@ 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.'
)
}
// Every string the repair below can write, so a repaired tree cannot be
// declared patched while one of the pieces is silently missing.
const requiredCreationTimeSources = [
['src/process.h', 'CREATIONTIME = 4'],
['src/process.h', 'ULONGLONG creationTimeMs'],
['src/process.cc', 'GetProcessCreationTime(pinfo)'],
['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'],
['src/process_worker.cc', 'object.Set("creationTimeMs"'],
['src/addon.cc', 'exports.Set("supportedProcessDataFlags"'],
['lib/index.js', '["CreationTime"] = 4'],
['lib/index.js', 'exports.supportedProcessDataFlags'],
['lib/index.js', 'creationTimeMs,'],
['lib/index.ts', 'CreationTime = 4'],
['lib/index.ts', 'export const supportedProcessDataFlags'],
['lib/index.ts', 'creationTimeMs,'],
['typings/windows-process-tree.d.ts', 'creationTimeMs?: number'],
// A regex because IProcessInfo declares the same field: only the tree node
// is followed by `children`, and that is the one buildNode fills.
['typings/windows-process-tree.d.ts', /creationTimeMs\?: number;\r?\n\s*children:/],
['typings/windows-process-tree.d.ts', 'export const supportedProcessDataFlags']
]
for (const [relativePath, expected] of requiredCreationTimeSources) {
const source = readFileSync(join(PACKAGE_DIR, relativePath), 'utf8')
const present = typeof expected === 'string' ? source.includes(expected) : expected.test(source)
if (!present) {
throw new Error(
`${relativePath} does not contain the process creation-time patch (${expected}). ` +
'Run pnpm install before building the relay addon.'
)
}
}
}
function repairCreationTimeSources() {
let repaired = false
const rewrite = (relativePath, transform) => {
const filePath = join(PACKAGE_DIR, relativePath)
const source = readFileSync(filePath, 'utf8')
const next = transform(source, source.includes('\r\n') ? '\r\n' : '\n')
if (next !== source) {
writeFileSync(filePath, next)
repaired = true
}
}
rewrite('src/process.h', (source, eol) => {
let next = source
if (!next.includes('ULONGLONG creationTimeMs')) {
next = next.replace(
/ std::string commandLine;\r?\n/,
` std::string commandLine;${eol} ULONGLONG creationTimeMs;${eol}`
)
}
if (!next.includes('CREATIONTIME = 4')) {
next = next.replace(
/ COMMANDLINE = 2\r?\n/,
` COMMANDLINE = 2,${eol} CREATIONTIME = 4${eol}`
)
}
if (!next.includes('void GetProcessCreationTime')) {
next = next.replace(
/void GetProcessMemoryUsage\(ProcessInfo& process_info\);\r?\n/,
`void GetProcessMemoryUsage(ProcessInfo& process_info);${eol}${eol}` +
`void GetProcessCreationTime(ProcessInfo& process_info);${eol}`
)
}
return next
})
rewrite('src/process.cc', (source, eol) => {
let next = source.replace('ProcessInfo pinfo;', 'ProcessInfo pinfo{};')
if (!next.includes('GetProcessCreationTime(pinfo)')) {
next = next.replace(
/( if \(COMMANDLINE & process_data_flags\) \{\r?\n GetProcessCommandLine\(pinfo\);\r?\n \})/,
`$1${eol}${eol} if (CREATIONTIME & process_data_flags) {${eol}` +
` GetProcessCreationTime(pinfo);${eol} }`
)
}
if (!next.includes('void GetProcessCreationTime(ProcessInfo& process_info) {')) {
const producer = [
'void GetProcessCreationTime(ProcessInfo& process_info) {',
' HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid);',
' if (hProcess == NULL) {',
' return;',
' }',
'',
' FILETIME creationTime, exitTime, kernelTime, userTime;',
' if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) {',
' ULARGE_INTEGER timestamp;',
' timestamp.LowPart = creationTime.dwLowDateTime;',
' timestamp.HighPart = creationTime.dwHighDateTime;',
' constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL;',
' constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL;',
' if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) {',
' process_info.creationTimeMs =',
' (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND;',
' }',
' }',
'',
' CloseHandle(hProcess);',
'}',
''
].join(eol)
next = next.replace(
'void GetProcessMemoryUsage',
`${producer}${eol}void GetProcessMemoryUsage`
)
}
return next
})
rewrite('src/process_worker.cc', (source, eol) => {
if (source.includes('object.Set("creationTimeMs"')) {
return source
}
const emission = [
' if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) {',
' object.Set("creationTimeMs",',
' Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs)));',
' }',
''
].join(eol)
return source.replace(
' result.Set(i, object);',
`${emission}${eol} result.Set(i, object);`
)
})
rewrite('src/addon.cc', (source, eol) => {
if (source.includes('exports.Set("supportedProcessDataFlags"')) {
return source
}
return source.replace(
/( exports\.Set\("getProcessCpuUsage", Napi::Function::New\(env, GetProcessCpuUsage\)\);\r?\n)/,
`$1 exports.Set("supportedProcessDataFlags",${eol}` +
` Napi::Number::New(env, MEMORY | COMMANDLINE | CREATIONTIME));${eol}`
)
})
// Each piece is guarded on its own: an early-out on the enum alone would let a
// tree with the enum but no buildNode splat pass as repaired.
const NATIVE_CONST =
"const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;"
for (const relativePath of ['lib/index.ts', 'lib/index.js']) {
const isTs = relativePath.endsWith('.ts')
rewrite(relativePath, (source, eol) => {
let next = source
if (!next.includes('CreationTime')) {
next = isTs
? next.replace(' CommandLine = 2', ` CommandLine = 2,${eol} CreationTime = 4`)
: next.replace(
' ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";',
' ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";' +
`${eol} ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime";`
)
}
if (!next.includes('supportedProcessDataFlags')) {
const reExport = isTs
? `/** The flag bits this compiled addon reports; undefined off win32. */${eol}` +
'export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags;'
: 'exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;'
next = next.replace(NATIVE_CONST, `${NATIVE_CONST}${eol}${reExport}`)
}
// buildNode drops any field it does not name, so the destructure and the
// splat have to move together.
next = next.replace(/(memory, commandLine)( \}, children \})/, '$1, creationTimeMs$2')
if (!/\bcreationTimeMs,/.test(next)) {
next = next.replace(
/(\r?\n)(\s*)commandLine,(\r?\n\s*children:)/,
`$1$2commandLine,$1$2creationTimeMs,$3`
)
}
return next
})
}
rewrite('typings/windows-process-tree.d.ts', (source, eol) => {
let next = source
if (!next.includes('CreationTime = 4')) {
next = next.replace(' CommandLine = 2', ` CommandLine = 2,${eol} CreationTime = 4`)
}
if (!next.includes('supportedProcessDataFlags')) {
next = next.replace(
/( CreationTime = 4\r?\n \}\r?\n)/,
`$1${eol} /** The flag bits the compiled addon reports; undefined off win32. */${eol}` +
` export const supportedProcessDataFlags: number | undefined;${eol}`
)
}
if (!next.includes('creationTimeMs?: number')) {
next = next.replace(
/ commandLine\?: string;\r?\n/,
` commandLine?: string;${eol}${eol}` +
` /** Process creation time in Unix milliseconds. */${eol}` +
` creationTimeMs?: number;${eol}`
)
}
// IProcessTreeNode is the second declaration; only it is followed by children.
next = next.replace(
/( commandLine\?: string;\r?\n)( children:)/,
`$1 creationTimeMs?: number;${eol}$2`
)
return next
})
return repaired
}
// pnpm can materialize this CRLF package without applying its patch. Repair the
@@ -123,6 +336,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)
@@ -130,8 +350,15 @@ function applyWindowsProcessTreeBuildFixes() {
if (processCc !== originalProcess) {
writeFileSync(processPath, processCc)
}
const repairedCreationTime = repairCreationTimeSources()
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR)
if (
bindingGyp !== originalBinding ||
processCc !== originalProcess ||
repairedCommandLine ||
repairedCreationTime
) {
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
}
}
@@ -173,6 +400,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(
@@ -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 <rev>]
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')
+88 -18
View File
@@ -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).`
)
}
}
+129 -3
View File
@@ -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 }))
@@ -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\}/)
})
})
@@ -44,6 +44,135 @@ describe('packaged runtime resources', () => {
}
})
it('verifies literal dynamic imports from the packaged main bundle', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-dynamic-imports-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
// The first is the exact shape oxc emits for the memoized SDK import in a
// shipped build; the second is the spaced variant the pattern also accepts.
const sources = new Map([
[
'out/main/index.js',
'let p=null;function q(){return p??=import(`@anthropic-ai/claude-agent-sdk`),p}'
],
[
'out/main/agent-hooks/managed-agent-hook-controls.js',
'import (`@anthropic-ai/claude-agent-sdk`)'
]
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
/@anthropic-ai\/claude-agent-sdk/
)
await mkdir(join(resourcesDir, 'node_modules', '@anthropic-ai', 'claude-agent-sdk'), {
recursive: true
})
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('still fails when a required packaged main entry is missing entirely', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-missing-entry-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
const asar = {
listPackage: () => ['/out/main/index.js'],
extractFile: () => Buffer.from('', 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
/managed-agent-hook-controls\.js was not found/
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('verifies bare imports that rolldown hoisted into a shared main chunk', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-chunk-imports-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
// The entry points themselves carry no specifier; only the shared chunk does.
const sources = new Map([
['out/main/index.js', ''],
['out/main/agent-hooks/managed-agent-hook-controls.js', ''],
['out/main/chunks/managed-agent-hook-controls-CWf8D-KR.js', 'require(`jsonc-parser`)']
])
// Real listPackage emits directory nodes too, and extractFile throws on them,
// so the `.js` anchor is load-bearing -- keep the mock able to catch that.
const directories = ['/out', '/out/main', '/out/main/chunks']
const asar = {
listPackage: () => [...directories, ...[...sources.keys()].map((entry) => `/${entry}`)],
extractFile: (_asarPath, internalPath) => {
const source = sources.get(internalPath)
if (source === undefined) {
throw new Error(`Expected to find file at: ${internalPath} but found a directory`)
}
return Buffer.from(source, 'utf8')
}
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
await mkdir(join(resourcesDir, 'node_modules', 'jsonc-parser'), { recursive: true })
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('reads a spread require, whose leading dots are not member access', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-spread-require-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
const sources = new Map([
['out/main/index.js', 'const all=[...require("jsonc-parser")]'],
['out/main/agent-hooks/managed-agent-hook-controls.js', '']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('ignores member calls onto Orca methods that are themselves named require', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-member-require-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
// electron-sidecar-tab-registry and browser-execution-host-grant-registry both
// expose require(key); a literal key must never read as a packaged specifier.
const sources = new Map([
['out/main/index.js', 'registry.require("public-a");grants.require(`host-key`)'],
['out/main/agent-hooks/managed-agent-hook-controls.js', 'state.import("android-sdk")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('normalizes host-specific asar entry separators', () => {
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
'\\out\\main\\index.js'
@@ -134,6 +263,15 @@ describe('packaged runtime resources', () => {
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
})
it('includes the Claude agent SDK in every desktop package plan', () => {
for (const platform of ['darwin', 'linux', 'win32']) {
const packagedTargets = createPackagedRuntimeNodeModuleResources(platform).map(
(resource) => resource.to
)
expect(packagedTargets).toContain(join('node_modules', '@anthropic-ai', 'claude-agent-sdk'))
}
})
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
+34 -7
View File
@@ -2,12 +2,19 @@
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync, realpathSync } 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')
const { assertWindowsProcessTreeCreationTime } = require('./windows-process-tree-creation-time.cjs')
const scriptPath = import.meta.filename
const projectDir = resolve(import.meta.dirname, '../..')
const runtime = readRuntimeArg()
@@ -253,11 +260,19 @@ 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.
require(moduleName)
// 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, reads every process's command
// line out of its address space, and ignores the CreationTime flag. Check
// the binary on both counts, not the load.
assertWindowsProcessTreeCreationTime({ module: 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') {
@@ -367,7 +382,19 @@ function getWindowsBuildNumber() {
function rebuildNodeRuntimeModules(moduleNames) {
for (const moduleName of moduleNames) {
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
let 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. The patched binding.gyp then includes
// deps/node-addon-api, which the tarball does not ship, and node-gyp must
// run from the physical dir -- both reasons live in
// windows-process-tree-gyp-rebuild.mjs.
ensureWindowsProcessTreeCommandLinePatch(moduleDir)
stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir)
moduleDir = realpathSync(moduleDir)
}
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
if (moduleName === 'node-pty' && process.platform === 'win32') {
+16 -13
View File
@@ -12,11 +12,15 @@ 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(
new URL('./node-pty-job-ownership.cjs', import.meta.url)
)
// The import walk sees `from './x.mjs'` only, so the createRequire'd CJS
// siblings have to be named. Without them the temp project cannot even load.
const REQUIRED_CJS_SIBLINGS = [
'node-pty-job-ownership.cjs',
'windows-process-tree-creation-time.cjs'
]
describe('ensure-native-runtime', () => {
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
@@ -27,7 +31,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 +70,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 +104,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 +138,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 +171,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,11 +197,15 @@ describe('ensure-native-runtime', () => {
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
)
// 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'))
for (const name of REQUIRED_CJS_SIBLINGS) {
copyFileSync(
fileURLToPath(new URL(`./${name}`, import.meta.url)),
join(projectDir, 'config', 'scripts', name)
)
}
return projectDir
}
@@ -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))
@@ -101,29 +101,112 @@ function constantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
}
function serializeEmbeddedModule(guides) {
const markdownConstants = guides
function fullConstantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_FULL_MARKDOWN`
}
function referenceConstantName(guideName, referenceName) {
return `${`${guideName}_${referenceName}`.replace(/-/g, '_').toUpperCase()}_REFERENCE_MARKDOWN`
}
function composeFullMarkdown(markdown, references) {
if (references.length === 0) {
return markdown
}
const packageHeader =
'\n\n---\n\n# Bundled references\n\n' +
'These references belong to the version-matched guide above. Read only the documents ' +
'named by its action gates.\n'
const documents = references
.map(
(guide) =>
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
({ relativePath, markdown: referenceMarkdown }) =>
`\n<!-- bundled-reference: ${relativePath} -->\n\n${referenceMarkdown.trimEnd()}\n`
)
.join('')
return `${markdown.trimEnd()}${packageHeader}${documents}`
}
function serializeEmbeddedModule(guides) {
const referenceConstants = guides.flatMap((guide) =>
guide.references.map((reference) => referenceConstantName(guide.name, reference.name))
)
// Why: the constant name flattens guide and reference names, so two topics could otherwise
// produce one identifier and silently serve the wrong reference.
if (new Set(referenceConstants).size !== referenceConstants.length) {
throw new Error(`Guide reference constant names collide: ${referenceConstants.join(', ')}`)
}
const markdownConstants = guides
.flatMap((guide) => {
const constants = [
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
]
if (guide.fullMarkdown !== guide.markdown) {
constants.push(
`// oxfmt-ignore\nconst ${fullConstantName(guide.name)} = ${JSON.stringify(guide.fullMarkdown)}`
)
}
for (const reference of guide.references) {
constants.push(
`// oxfmt-ignore\nconst ${referenceConstantName(guide.name, reference.name)} = ${JSON.stringify(reference.markdown)}`
)
}
return constants
})
.join('\n\n')
const guideEntries = guides
.map((guide) => {
const markdownConstant = constantName(guide.name)
const referenceEntries = guide.references
.map(
(reference) =>
`{ name: ${JSON.stringify(reference.name)}, markdown: ${referenceConstantName(guide.name, reference.name)} }`
)
.join(', ')
return [
' {',
` name: ${JSON.stringify(guide.name)},`,
` description: ${JSON.stringify(guide.description)},`,
` markdown: ${markdownConstant},`,
` fullMarkdown: ${markdownConstant},`,
` aliases: ${JSON.stringify(guide.aliases)}`,
` fullMarkdown: ${guide.fullMarkdown === guide.markdown ? markdownConstant : fullConstantName(guide.name)},`,
` aliases: ${JSON.stringify(guide.aliases)},`,
` references: [${referenceEntries}]`,
' }'
].join('\n')
})
.join(',\n')
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
}
async function readGuideReferences(repoRoot, guideName) {
const referenceRoot = path.join(repoRoot, 'skill-guides', guideName, 'references')
let entries
try {
entries = await readdir(referenceRoot, { withFileTypes: true })
} catch (error) {
if (error.code === 'ENOENT') {
return []
}
throw error
}
const unsupported = entries.find((entry) => !entry.isFile() || !entry.name.endsWith('.md'))
if (unsupported) {
throw new Error(
`Guide references must be Markdown files: skill-guides/${guideName}/references/${unsupported.name}`
)
}
return Promise.all(
entries
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
.map(async (entry) => {
const sourcePath = path.join(referenceRoot, entry.name)
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
if (!markdown.trim()) {
throw new Error(`Guide reference is empty: ${toPosixRelativePath(repoRoot, sourcePath)}`)
}
return { name: entry.name.slice(0, -3), relativePath: `references/${entry.name}`, markdown }
})
)
}
function assertAliasContract(guides) {
@@ -204,9 +287,22 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
}
const aliases = GUIDE_ALIASES[name]
const references = await readGuideReferences(repoRoot, name)
// Why: the embedded table always carries the full guide (served by `skills get`);
// only the installable projection thins to a stub once a topic is in STUB_TOPICS.
guides.push({ name, description: frontmatter.description, markdown, aliases })
guides.push({
name,
description: frontmatter.description,
markdown,
fullMarkdown: composeFullMarkdown(markdown, references),
aliases,
// Why: `skills get --reference` serves one of these alone, so it keeps the
// per-file identity that fullMarkdown's concatenation erases.
references: references.map(({ name: referenceName, markdown: referenceMarkdown }) => ({
name: referenceName,
markdown: referenceMarkdown
}))
})
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
const content = stubTopics.has(name)
? composeStubProjection(markdown, await readFile(stubPath, 'utf8'), `skill-stubs/${name}.md`)
@@ -273,6 +369,7 @@ export {
STUB_TOPICS,
assertAliasContract,
buildArtifacts,
composeFullMarkdown,
composeStubProjection,
frontmatterBlock,
normalizeMarkdown,
@@ -22,6 +22,15 @@ import {
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
const execFileAsync = promisify(execFile)
const ORCHESTRATION_REFERENCES = [
'coordinator-loop.md',
'legacy-contract-migration.md',
'low-level-topology.md',
'messaging-and-gates.md',
'placement-and-remote.md',
'recovery-and-cleanup.md',
'worker-contract.md'
]
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
@@ -181,7 +190,7 @@ describe('bundled skill guide generator', () => {
}
)
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
it('embeds compact guides, version-matched reference packages, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
)
@@ -194,8 +203,46 @@ describe('bundled skill guide generator', () => {
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
expect(guide.description).toBe(frontmatter.description)
expect(guide.markdown).toBe(source)
expect(guide.fullMarkdown).toBe(source)
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
if (guide.name !== 'orchestration') {
expect(guide.fullMarkdown).toBe(source)
expect(guide.references).toEqual([])
continue
}
// Why: the per-reference selector serves these verbatim, so an entry that
// drifts from the file on disk ships a stale reference to every agent.
expect(guide.references.map((reference) => reference.name)).toEqual(
ORCHESTRATION_REFERENCES.map((reference) => reference.replace(/\.md$/u, ''))
)
for (const reference of guide.references) {
expect(reference.markdown).toBe(
normalizeMarkdown(
await readFile(
path.join(
projectDir,
'skill-guides',
'orchestration',
'references',
`${reference.name}.md`
),
'utf8'
)
)
)
}
expect(guide.fullMarkdown).not.toBe(guide.markdown)
expect(guide.fullMarkdown.length).toBeGreaterThan(guide.markdown.length)
expect(guide.fullMarkdown.startsWith(source.trimEnd())).toBe(true)
for (const reference of ORCHESTRATION_REFERENCES) {
const marker = `<!-- bundled-reference: references/${reference} -->`
expect(guide.fullMarkdown.split(marker)).toHaveLength(2)
expect(guide.fullMarkdown).toContain(
await readFile(
path.join(projectDir, 'skill-guides', 'orchestration', 'references', reference),
'utf8'
)
)
}
}
})
@@ -237,6 +284,17 @@ describe('bundled skill guide generator', () => {
const stubSource = await readFile(stubPath, 'utf8')
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
}
for (const reference of ORCHESTRATION_REFERENCES) {
const referencePath = path.join(
root,
'skill-guides',
'orchestration',
'references',
reference
)
const source = await readFile(referencePath, 'utf8')
await writeFile(referencePath, source.replaceAll('\n', '\r\n'))
}
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
@@ -303,4 +361,15 @@ describe('bundled skill guide generator', () => {
])
).toThrow('collides with canonical name')
})
it('rejects non-Markdown and empty bundled references', async () => {
const root = await createFixture()
const referenceRoot = path.join(root, 'skill-guides', 'orchestration', 'references')
await writeFile(path.join(referenceRoot, 'notes.txt'), 'not a reference\n')
await expect(buildArtifacts(root)).rejects.toThrow('Guide references must be Markdown files')
await rm(path.join(referenceRoot, 'notes.txt'))
await writeFile(path.join(referenceRoot, 'empty.md'), '\n')
await expect(buildArtifacts(root)).rejects.toThrow('Guide reference is empty')
})
})
@@ -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 <baseline-ref>')
}
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))
@@ -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 <baseline-ref>'
)
}
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<p>Use `Array<string>` and <b>bold</b>.</p>'],
...[2048, 8192, 16384].map((length) => [
`${length} underscore collision`,
`\uE000ORCA_MD_CODE_${'_'.repeat(length)}0\uE000 and \`Array<string>\``
])
]) {
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))
@@ -10,7 +10,14 @@ const guidePath = join(projectDir, 'skill-guides', 'orca-cli.md')
const stubPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
// Why: orchestration and orca-emulator also ship hybrid stubs now, so their version-sensitive
// command guidance lives in the guide sources — read the cross-guide worktree-id contract there.
const orchestrationSkillPath = join(projectDir, 'skill-guides', 'orchestration.md')
// Why: the worktree-selector rule lives in the orchestration placement reference, not the kernel.
const orchestrationPlacementPath = join(
projectDir,
'skill-guides',
'orchestration',
'references',
'placement-and-remote.md'
)
const emulatorSkillPath = join(projectDir, 'skill-guides', 'orca-emulator.md')
function readSkill(path = guidePath) {
@@ -95,7 +102,7 @@ describe('orca CLI skill guidance', () => {
it('requires full worktree ids across bundled agent guidance', () => {
const cliSkill = readSkill()
const orchestrationSkill = readSkill(orchestrationSkillPath)
const orchestrationSkill = readSkill(orchestrationPlacementPath)
const emulatorSkill = readSkill(emulatorSkillPath)
for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) {
@@ -0,0 +1,38 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { ORCHESTRATION_COMMAND_SPECS } from '../../src/cli/specs/orchestration'
const projectDir = resolve(import.meta.dirname, '../..')
const guideRoot = join(projectDir, 'skill-guides', 'orchestration')
const guidePaths = [
join(projectDir, 'skill-guides', 'orchestration.md'),
...readdirSync(join(guideRoot, 'references')).map((name) => join(guideRoot, 'references', name))
]
function documentedInvocations() {
return guidePaths.flatMap((path) => {
const text = readFileSync(path, 'utf8')
return [...text.matchAll(/ORCA orchestration ([a-z-]+)([^`\n]*)/gu)].map((match) => ({
path,
verb: match[1],
flags: [...match[2].matchAll(/(?:^|\s)--([a-z][a-z-]*)/gu)].map((flag) => flag[1])
}))
})
}
describe('orchestration guide command contract', () => {
it('documents only orchestration verbs and flags accepted by the CLI specs', () => {
const specs = new Map(
ORCHESTRATION_COMMAND_SPECS.map((spec) => [spec.path[1], new Set(spec.allowedFlags)])
)
for (const invocation of documentedInvocations()) {
const allowed = specs.get(invocation.verb)
expect(allowed, `${invocation.path}: ${invocation.verb}`).toBeDefined()
for (const flag of invocation.flags) {
expect(allowed, `${invocation.path}: ${invocation.verb} --${flag}`).toContain(flag)
}
}
})
})
@@ -1,32 +1,58 @@
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
// Why: orchestration now ships a hybrid discovery stub, so its version-sensitive command
// guidance lives in the authoritative guide source — assert that content there. The
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'orchestration.md')
const referenceRoot = join(projectDir, 'skill-guides', 'orchestration', 'references')
const stubPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
function readSkill() {
function readKernel() {
return readFileSync(guidePath, 'utf8')
}
function getSection(markdown, heading) {
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = markdown.match(
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
)
expect(match).not.toBeNull()
return match?.[1] ?? ''
function readReference(name) {
return readFileSync(join(referenceRoot, name), 'utf8')
}
describe('orchestration skill guidance', () => {
function frontmatter(text) {
return /^---\n[\s\S]*?\n---\n/u.exec(text)?.[0]
}
function squash(text) {
return text.replace(/\s+/gu, ' ').trim()
}
// Routing lives in the frontmatter description alone; the body must not satisfy these.
function readDescription() {
return squash(frontmatter(readKernel()))
}
describe('orchestration skill routing', () => {
it('keeps the verbatim routing triggers a model matches the skill on', () => {
const description = readDescription()
for (const trigger of [
'threaded messages',
'worker_done/escalation waits',
'decision gates',
'decomposing work across agents',
'"hand off"',
'"handoff"',
'"handover"',
'"give this to another agent"',
'"another worktree"',
'lightweight terminal prompts',
'shell commands',
'Orca worktree management',
'reading or waiting on terminals'
]) {
expect(description).toContain(trigger)
}
})
it('keeps external browser routing at the OS/page boundary', () => {
const description = readFileSync(guidePath, 'utf8').replace(/\s+/gu, ' ')
const description = readDescription()
expect(description).toContain(
"Use Computer Use for external browser windows, webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots."
@@ -35,383 +61,444 @@ describe('orchestration skill guidance', () => {
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
)
})
})
it('requires Orca runtime state before claiming a worker was orchestrated', () => {
const skill = readSkill()
const toolBoundary = getSection(skill, 'Tool Boundary')
describe('orchestration kernel', () => {
it('keeps the always-loaded guide compact and ordered around the normal protocol', () => {
const kernel = readKernel()
const headings = [
'## Outcome',
'## Classify the role',
'## Authority and safety floor',
'## Worker obligations',
'## Canonical supervised loop',
'## Task-spec contract',
'## Completion accounting',
'## Conditional references'
]
expect(toolBoundary).toContain('must create or bind a Run')
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
expect(toolBoundary).not.toContain('or `orca orchestration run`')
expect(skill).toContain(
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
)
expect(toolBoundary).toContain(
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
)
expect(toolBoundary).toContain('do not create Orca task/dispatch provenance')
expect(toolBoundary).toContain('injected lifecycle preambles')
expect(toolBoundary).toContain('`worker_done` authority')
expect(toolBoundary).toContain('decision gates')
expect(toolBoundary).toContain('orca orchestration task-list --json')
expect(toolBoundary).toContain('orca orchestration dispatch-show --task <task_id> --json')
expect(toolBoundary).toContain(
'do not retroactively describe the external worker as orchestrated'
)
})
it('teaches attested adoption without reviving the retired scheduler', () => {
const skill = readSkill()
const migration = getSection(skill, 'Contract Migration')
expect(migration).toContain(
'adopts a live pre-update orchestration assignment into an ordinary Run'
)
expect(migration).toContain(
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
)
expect(migration).toContain('never restarts or replaces the worker')
expect(migration).toContain('The retired scheduler is not revived')
expect(migration).toContain('[LEGACY COMPATIBILITY]')
expect(migration).toContain('[LEGACY READ-ONLY]')
expect(migration).toContain(
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
)
expect(migration).toContain(
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
)
expect(migration).not.toContain('task-list --run run_legacy_local')
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
expect(migration).toContain('Recovered orchestration work from a contract update')
expect(migration).toContain('run-show --id <adopted_run_id>')
expect(migration).toContain('task-list --run <adopted_run_id>')
expect(migration).toContain('Legacy inspection remains available without consuming mail')
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
expect(migration).toContain('Takeover fences only the old coordinator')
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
expect(migration).toContain(
'keep the original worker as the only editor until it reaches a stable handoff point'
)
expect(migration).toContain('a conflict-free placement for any remaining work')
})
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
const skill = readSkill()
expect(skill).toContain('Treat a `check --wait` timeout or `{count:0}` as a checkpoint')
expect(skill).toContain('Do not stop, close, kill, or restart a worker')
expect(skill).toContain('keep waiting instead of retrying the task')
expect(skill).not.toContain(
'If `check --wait` times out with no `worker_done` or `escalation`, fall back to `terminal wait --for tui-idle`, then `terminal read`.'
)
})
it('keeps full handoffs out of dispatch lifecycle and off the active branch base', () => {
const skill = readSkill()
const fullHandoffs = getSection(skill, 'Full Handoffs')
expect(skill).toContain('Full handoff means ownership transfer, not supervised dispatch.')
expect(fullHandoffs).toContain(
'Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs.'
)
expect(fullHandoffs).toContain(
'`task-create` is also forbidden because it records coordinator-owned tracking state'
)
expect(fullHandoffs).toContain('Do not create a `taskId`/`dispatchId`')
expect(fullHandoffs).toContain(
'read the worker terminal after prompt delivery except to avoid losing the initial prompt'
)
expect(skill).toContain(
'`--no-parent` only controls Orca lineage; it does not choose the Git base.'
)
expect(skill).toContain(
'never base it on the current feature branch unless the user explicitly asks'
)
expect(skill).toContain(
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
)
expect(fullHandoffs).toContain(
'Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level'
)
expect(fullHandoffs).toContain(
'Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree'
)
expect(fullHandoffs).toContain(
'For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`'
)
expect(fullHandoffs).toContain('If the work should start from the repo default base')
expect(fullHandoffs).toContain('omit `--base-branch`')
})
it('classifies handoff wording as ownership transfer unless supervision is explicit', () => {
const skill = readSkill()
const fullHandoffs = getSection(skill, 'Full Handoffs')
for (const phrase of [
'hand off',
'handoff',
'handover',
'give this to another agent',
'give this to another worktree',
'another agent',
'another worktree'
]) {
expect(fullHandoffs).toContain(phrase)
// Why: 202 is the budget after the anti-loop nextAction rule; the kernel is always in context.
expect(kernel.split('\n').length).toBeLessThanOrEqual(202)
for (let index = 1; index < headings.length; index += 1) {
expect(kernel.indexOf(headings[index])).toBeGreaterThan(kernel.indexOf(headings[index - 1]))
}
expect(kernel).not.toContain('## Contract Migration')
expect(kernel).not.toContain('## Full Handoffs')
expect(kernel).not.toContain('## Worker Terminals')
})
for (const supervisionPhrase of [
'supervise',
'monitor',
'wait for worker_done',
'wait for results',
'track completion',
'DAG',
'decision gate',
'ask/reply'
it('classifies coordinator, dispatched worker, handoff, compatibility, and ordinary roles', () => {
const kernel = readKernel()
expect(kernel).toContain('explicitly asks to supervise, monitor, wait for results')
expect(kernel).toContain('live injected preamble with Task and Dispatch IDs')
expect(kernel).toContain('Handoff owner')
expect(kernel).toContain('create no Run, Task, or Dispatch and do not monitor completion')
expect(kernel).toContain('Compatibility operator')
expect(kernel).toContain('Ordinary terminal agent')
expect(kernel).toContain('Model or effort selection does not make a handoff supervised')
expect(squash(kernel)).toContain('Never substitute a non-Orca subagent tool')
})
it('makes Dispatch identity, remote uncertainty, folders, and mixed versions a safety floor', () => {
const kernel = readKernel()
expect(kernel).toContain('A Dispatch is one authoritative Task attempt')
expect(kernel).toContain('Lifecycle authority comes from the active Dispatch')
expect(kernel).toContain('execution host owns')
expect(squash(kernel)).toContain('`live` / `unverifiable` / `exited`')
expect(kernel).toContain('contact loss is not process death')
expect(kernel).toContain('Folder workspaces are valid')
expect(squash(kernel)).toContain('Treat unknown optional fields as absent')
expect(kernel).toContain('new stream operation requires advertised capability')
expect(kernel).toContain('Never fall back to local execution')
})
it('puts exactly-once worker completion and post-completion idle before coordinator mechanics', () => {
const kernel = readKernel()
expect(kernel.indexOf('## Worker obligations')).toBeLessThan(
kernel.indexOf('## Canonical supervised loop')
)
expect(kernel).toContain('The injected preamble is authoritative')
expect(kernel).toContain('Send `worker_done` exactly once')
expect(kernel).toContain('three-sentence executive summary')
expect(kernel).toContain('`--outcome succeeded` or `--outcome failed`')
// Why: the runnable worker_done command is the preamble's; its flag spellings are pinned
// on worker-contract.md by 'keeps heartbeat and worker_done recipes bound to the injected
// capability', so the kernel carries the obligations as prose and no third copy.
expect(kernel).not.toContain('--type worker_done')
expect(kernel).toContain('After `worker_done`, end the dispatched turn and idle')
expect(kernel).toContain('Do not reuse the settled lifecycle IDs')
})
it('teaches worker-start as the only normal-path launch and starts the wave before waiting', () => {
const kernel = readKernel()
const firstStart = kernel.indexOf('worker-start --spec "<worker A task>"')
const secondStart = kernel.indexOf('worker-start --spec "<worker B task>"')
const firstWait = kernel.indexOf('check --wait')
expect(firstStart).toBeGreaterThan(kernel.indexOf('run-create'))
expect(secondStart).toBeGreaterThan(firstStart)
expect(firstWait).toBeGreaterThan(secondStart)
expect(squash(kernel)).toContain('start the full independent wave before waiting')
expect(kernel).toContain('`worker-start` is the normal path')
expect(squash(kernel)).toContain(
"If `worker-start` exits non-zero, do not relaunch. Read the receipt's `failedStage` and `residualResources`"
)
expect(kernel).toContain('operator-created process unsupervised')
expect(kernel).not.toMatch(/^ORCA terminal create/mu)
})
it('makes worker-start --spec the default and keeps task-create for planned fan-out', () => {
const kernel = squash(readKernel())
expect(kernel).toContain('`worker-start --spec` creates the Task and its attempt in one call')
expect(kernel).toContain('Use `task-create` plus `worker-start --task <task_id>`')
})
it('gives the supervised loop an exit condition for a live terminal with a dead agent', () => {
const kernel = squash(readKernel())
expect(kernel).toContain("`worker-list`'s `projection.liveness` is the fleet verdict")
expect(kernel).toContain("`worker-show`'s `observation.status` is PTY liveness only")
expect(kernel).toContain('After three consecutive empty waits')
expect(kernel).toContain('`ORCA orchestration worker-list --include-remote --json`')
expect(kernel).toContain('defaults to the bound Run; `--run <run_id>` overrides')
expect(kernel).toContain(
'`projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv'
)
expect(kernel).toContain(
'An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false is informational, not a command to re-run: keep waiting with `check --wait`'
)
expect(kernel).toContain('choose `worker-stop` or `worker-abandon`')
})
it('lets only positive evidence of exit end a wait', () => {
const kernel = squash(readKernel())
expect(kernel).toContain('Leave the wait only on positive proof the agent stopped')
expect(kernel).toContain('`exited` liveness')
expect(kernel).toContain("the worker's own observation of process exit")
expect(kernel).toContain('transcript whose final agent turn sent no `worker_done`')
expect(kernel).toContain(
'`unverifiable` is absence, including when `worker-show` reports `agentWait` null. Absence never authorizes stop, abandon, retry, or release'
)
})
it('names --terminal, never --from, as the check caller flag', () => {
const kernel = squash(readKernel())
expect(kernel).toContain('`check` names its caller with `--terminal <handle>`, never `--from`')
expect(kernel).not.toContain('check --from')
})
it('makes a dispatched worker read coordinator follow-ups on a cadence', () => {
const kernel = squash(readKernel())
expect(kernel).toContain('Read coordinator follow-ups at each natural checkpoint')
expect(kernel).toContain('once more immediately before `worker_done`')
expect(kernel).toContain('`ORCA orchestration check --terminal <your_handle> --json`')
})
it('requires full Delivery processing and settled-terminal accounting before ack', () => {
const kernel = readKernel()
expect(squash(kernel)).toContain(
'oldest FIFO Delivery and replays that batch until acknowledged'
)
expect(squash(kernel)).toContain('Process every message')
expect(squash(kernel)).toContain("decide each settled terminal's next owner before the ack")
expect(squash(kernel)).toContain('reused, explicitly retained, or released')
expect(squash(kernel)).toContain(
'the turn ends only when the report to that user names, per Task, its outcome, the evidence behind it, and any unresolved blocker'
)
expect(kernel).toContain('worker-release --dispatch <dispatch_id>')
expect(kernel).toContain('check --ack <delivery_id> --wait')
expect(squash(kernel)).toContain(
'`worker-list --run <run_id> --terminal-state reclaimable --json`'
)
expect(squash(kernel)).toContain('do not follow it with `task-update --status completed`')
})
it('treats long waits and release uncertainty as safe checkpoints', () => {
const kernel = readKernel()
// Why: e92d7812d91 and c78f40fdd0b protect one rule; `## Outcome` states it once and each
// gate cites it, so these pin the condition rather than a per-gate list of non-proofs.
expect(squash(kernel)).toContain(
'Only positive proof of exit authorizes stop, abandon, or retry, and only an accepted settlement authorizes release. Every other observation, absence included, is a checkpoint'
)
expect(squash(kernel)).toContain('A timeout or empty result is a checkpoint, not a failure')
expect(squash(kernel)).toContain('Do not stop, retry, release, or launch a duplicate editor')
expect(squash(kernel)).toContain('without the positive proof `## Outcome` requires')
expect(squash(kernel)).toContain(
'Only an accepted settlement authorizes it; no other observation does'
)
expect(kernel).toContain('never substitute `terminal close`')
})
it('defines self-contained task specs and honest send attention semantics', () => {
const kernel = readKernel()
for (const field of [
'**Target:**',
'**Change:**',
'**Constraints:**',
'**Ownership:**',
'**Observable acceptance:**'
]) {
expect(fullHandoffs).toContain(supervisionPhrase)
expect(kernel).toContain(field)
}
expect(kernel).toContain('successful `orchestration send` proves durable enqueue')
expect(kernel).toContain('best-effort attention only')
expect(squash(kernel)).toContain('does not prove the recipient read or accepted it')
})
})
describe('owned orchestration references', () => {
it('routes every conditional read to exactly one shipped reference', () => {
const kernel = readKernel()
const routed = [...kernel.matchAll(/`references\/([^`]+\.md)`/gu)].map((match) => match[1])
const shipped = readdirSync(referenceRoot)
.filter((name) => name.endsWith('.md'))
.sort()
const tableRoutes = [...kernel.matchAll(/^\|.*`references\/([^`]+\.md)`.*\|$/gmu)].map(
(match) => match[1]
)
expect([...new Set(routed)].sort()).toEqual(shipped)
// Why the table and not every mention: prose may cite a reference the gate table already routes.
expect(tableRoutes.sort()).toEqual(shipped)
expect(kernel).toContain('ORCA skills get orchestration --full')
// Why: the selector is the cheap path, so the kernel must teach it first and keep
// `--full` only as the fallback for a CLI build that predates it.
expect(squash(kernel)).toContain(
'run `ORCA skills get orchestration --reference references/<file>.md`'
)
expect(squash(kernel)).toContain(
'If the CLI rejects `--reference`, run `ORCA skills get orchestration --full`'
)
expect(squash(kernel)).toContain('If an older CLI rejects `--full`')
})
it('documents custom model and effort handoffs without completion monitoring', () => {
const skill = readSkill()
const fullHandoffs = getSection(skill, 'Full Handoffs')
it('owns expanded waves, launch preferences, reuse, and review boundaries', () => {
const reference = readReference('coordinator-loop.md')
expect(fullHandoffs).toContain('Custom Codex model/effort handoff')
expect(fullHandoffs).toContain(
'does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments'
)
expect(fullHandoffs).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
expect(fullHandoffs).toContain(
'Wait only for `tui-idle` when needed to avoid losing the prompt.'
)
expect(fullHandoffs).toContain('Do not monitor task completion.')
})
it('clarifies sidebar lineage for same-worktree orchestrated workers', () => {
const skill = readSkill()
const workerTerminals = getSection(skill, 'Worker Terminals')
expect(workerTerminals).toContain(
'Sidebar lineage and orchestration lifecycle are related but not identical.'
)
expect(workerTerminals).toContain(
'A same-worktree worker may appear as a peer under that worktree in the sidebar'
)
expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state')
expect(workerTerminals).toContain(
'only an actual child worktree creates visible parent/child worktree lineage'
)
expect(workerTerminals).toContain(
'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible'
)
expect(workerTerminals).toContain(
'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.'
)
expect(workerTerminals).toContain(
'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree'
)
expect(workerTerminals).toContain('use `--no-parent` when it is not stacked')
})
it('keeps review-only completions and named next-owner fixes in their lanes', () => {
const skill = readSkill()
expect(skill).toContain(
'A review-only `worker_done` reports findings; it does not authorize coordinator file edits.'
)
expect(skill).toContain('unless the user explicitly asked the coordinator to own fixes')
expect(skill).toContain('dispatch or hand off fixes')
expect(skill).toContain(
"If the user's plan names a next owner agent " +
'(for example, "then use opencode to create a PR")'
)
expect(skill).toContain('post-review corrections and PR prep belong to that named owner')
expect(skill).toContain('the named owner edits files and creates the PR')
})
it('keeps post-completion workers idle without subordinating the user', () => {
const skill = readSkill()
const agentGuidance = getSection(skill, 'Agent Guidance')
expect(agentGuidance).toContain('After sending `worker_done`, end that dispatched turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not autonomously start more work, poll')
expect(agentGuidance).toContain('A direct user instruction takes precedence')
expect(agentGuidance).toContain('follow it without coordinator approval or a fresh Dispatch')
expect(agentGuidance).toContain('never refuse it because of worker/coordinator roles')
expect(agentGuidance).toContain("do not reuse the settled Dispatch's lifecycle IDs")
expect(agentGuidance).toContain(
'A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block'
)
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('makes settled worker terminal release an explicit coordinator step', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
const nextAction = getSection(skill, 'Next Action')
expect(workerLoop).toContain(
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
'orca orchestration worker-release --dispatch <dispatch_id> --json'
)
expect(workerLoop).toContain(
'Acknowledge only after every message and required release decision is handled'
)
expect(workerLoop).toContain(
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
)
expect(workerLoop).toContain(
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
'transfers cleanup ownership to the new Dispatch'
)
expect(workerLoop).toContain(
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
'explicitly asked to keep that worker live.'
)
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
expect(workerLoop).toContain(
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
)
expect(agentGuidance).toContain(
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
'the turn'
)
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
expect(nextAction).toContain(
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
'follow-up Dispatch or run `worker-release` before the next wait.'
expect(reference).toContain('task-list --ready --brief --json')
expect(reference).toContain('`--effort` requires `--model`')
expect(reference).toContain('neither option combines with `--terminal`')
expect(reference).toContain('`launch.requested` with `launch.effective`')
expect(reference).toContain('worker-start --task <next_task_id> --terminal')
expect(reference).toContain('A review-only `worker_done` authorizes synthesis')
expect(squash(reference)).toContain(
'post-review fixes and PR preparation remain with that owner'
)
})
it('documents per-invocation model and effort for supervised workers', () => {
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
it('owns worker heartbeat, ask resume, escalation, failure, and idle', () => {
const reference = readReference('worker-contract.md')
expect(workerLoop).toContain('opaque provider model id with `--model`')
expect(workerLoop).toContain('`--effort` requires `--model`')
expect(workerLoop).toContain('neither option can combine with `--terminal`')
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
expect(reference).toContain('--type heartbeat')
expect(reference).toContain('--task-id <task_id> --dispatch-id <dispatch_id>')
expect(reference).toContain('--phase "<investigating|implementing|reviewing|waiting>"')
expect(reference).toContain('--resume <message_id>')
expect(reference).toContain('do not create a duplicate question')
expect(reference).toContain('--type escalation')
expect(reference).toContain('Send exactly one terminal report')
expect(reference).toContain('Use `--outcome failed`')
expect(reference).toContain('After `worker_done`, end the dispatched turn and idle')
expect(squash(reference)).toContain(
'ORCA orchestration check --terminal <worker_handle> --json'
)
expect(squash(reference)).toContain('once more immediately before `worker_done`')
expect(squash(reference)).toContain(
'`check` names its caller with `--terminal`, never `--from`'
)
expect(squash(reference)).toContain('If `check` returns `consumer_fenced`')
expect(squash(reference)).toContain('An empty `check` never means you were replaced')
})
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
it('keeps heartbeat and worker_done recipes bound to the injected capability', () => {
const reference = readReference('worker-contract.md')
const recipes = [...reference.matchAll(/```text\n([\s\S]*?)```/gu)].map((match) => match[1])
const heartbeat = recipes.find((recipe) => recipe.includes('--type heartbeat'))
const workerDone = recipes.find((recipe) => recipe.includes('--type worker_done'))
// The prohibition sentence is the guard the negative patterns below rely on.
expect(workerLoop).toContain(
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
'escalation, or rejected/stale `worker_done`.'
)
expect(workerLoop).toContain(
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
)
expect(skill).not.toMatch(
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
)
expect(skill).not.toMatch(
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
)
expect(agentGuidance).toContain(
'Do not autonomously start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
for (const recipe of [heartbeat, workerDone]) {
expect(recipe).toContain('--from <worker_handle>')
expect(recipe).toContain('--dispatch-capability <capability>')
expect(recipe).toContain('--task-id <task_id> --dispatch-id <dispatch_id>')
}
expect(workerDone).not.toContain('--files-modified')
expect(workerDone).not.toContain('--report-path')
expect(squash(reference)).toContain('only when applicable, using actual paths')
expect(reference).toContain('Do not send documentation placeholders as metadata')
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
it('owns local, folder, worktree, SSH, WSL, remote, and mixed-version placement', () => {
const reference = readReference('placement-and-remote.md')
expect(messaging).toContain('`@grok`')
expect(reference).toContain('--worktree current --agent codex')
expect(squash(reference)).toContain(
'A worktree selector needs the full `<repo-id>::<path>` value Orca returned, passed as `id:<newFullWorktreeId>`; a bare repo id is not a worktree id'
)
expect(reference).toContain('--worktree new-child')
expect(reference).toContain('--worktree new-top-level')
expect(reference).toContain('Folder workspaces are first-class')
expect(reference).toContain('Remote `current` and `new-child` are invalid')
expect(squash(reference)).toContain("`--on` selects only the worker's execution server")
expect(squash(reference)).toContain(
'route every follow-up, read, stop, and cleanup by Dispatch ID'
)
expect(reference).toContain('`live`, `unverifiable`, or `exited`')
expect(squash(reference)).toContain('unknown stream opcodes can be silently dropped')
expect(reference).toContain('printed `orca-ide`')
expect(squash(reference)).toContain(
'ORCA project setup-existing-folder --project <project_id> --host <host_id> --path <abs_path> --kind folder --json'
)
expect(squash(reference)).toContain('and rejects a plain directory')
expect(reference).toContain(
'ORCA orchestration worker-list --run <run_id> --include-remote --json'
)
expect(squash(reference)).toContain(
'enumerate remote workers with `--include-remote` or every one of them reads `unverifiable`'
)
})
it('documents @cursor in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
it('owns FIFO mail, Dispatch addresses, groups, questions, and gates', () => {
const reference = readReference('messaging-and-gates.md')
expect(messaging).toContain('`@cursor`')
expect(reference).toContain('oldest FIFO Delivery')
expect(squash(reference)).toContain('Process every row')
expect(squash(reference)).toContain(
'A Delivery therefore always carries the whole FIFO batch whatever its types, and a `check` without `--wait` hands that batch over unfiltered'
)
expect(reference).toContain('send --to dispatch:<dispatch_id>')
for (const group of ['@all', '@grok', '@cursor', '@worktree:<id>']) {
expect(reference).toContain(group)
}
expect(reference).toContain('Dispatch lifecycle messages never target groups')
expect(reference).toContain('gate-create --task <task_id>')
expect(reference).toContain("Do not create a gate merely to answer a worker's `ask`")
expect(reference).toContain('successful `send` proves durable enqueue')
expect(squash(reference)).toContain('Wake and nudge are best-effort attention only')
expect(squash(reference)).toContain(
'`check` names its caller with `--terminal <handle>` and is the only verb that rejects `--from`'
)
})
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
const workerTerminals = getSection(skill, 'Worker Terminals')
const agentFirstExample = workerTerminals.match(
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
)?.[0]
it('owns positive-evidence retry, unknown outcomes, retain/release, and no terminal close', () => {
const reference = readReference('recovery-and-cleanup.md')
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
expect(workerTerminals).toContain('fallback shell + agent pair')
expect(workerTerminals).toContain(
'repo setup and default-terminal settings may add intentional tabs or splits'
expect(squash(reference)).toContain('| `ready` or active | Keep waiting')
expect(squash(reference)).toContain('| `outcome_unknown` | Inspect')
expect(squash(reference)).toContain('| Remote contact lost | Preserve `unverifiable`')
expect(reference).toContain('--retry-of <dispatch_id>')
expect(squash(reference)).toContain('Placement is never silently inherited')
expect(reference).toContain('worker-abandon --dispatch')
expect(reference).toContain('worker-retain --dispatch')
expect(reference).toContain('worker-release --dispatch')
expect(squash(reference)).toContain('`release_pending` or `release_unknown`')
expect(squash(reference)).toContain('Never substitute `terminal close`')
})
it('owns the lost-response question and the request-show verdicts', () => {
const reference = squash(readReference('recovery-and-cleanup.md'))
expect(reference).toContain('request-show --request <request_id> --json')
expect(reference).toContain('--retry-request <request_id>')
expect(reference).toContain('`completed` means the mutation already took effect')
expect(reference).toContain('`pending` means the original mutation is still running')
expect(reference).toContain('that is not proof nothing happened')
expect(reference).toContain('terminal send --wait-submit <seconds>')
})
it('names worker-list as the enumerating command and the agent-liveness authority', () => {
const reference = squash(readReference('recovery-and-cleanup.md'))
expect(reference).toContain('ORCA orchestration worker-list --run <run_id> --json')
expect(reference).toContain("`worker-show`'s `observation.status` is PTY liveness only")
expect(reference).toContain(
'`projection.attention.categories`, `projection.attention.requiresAction`'
)
expect(workerTerminals).toContain('without configured default tabs')
expect(workerTerminals).toContain(
'only after `terminal list` or `terminal show` confirms it is an unused shell'
expect(reference).toContain('`projection.nextAction` argv')
expect(reference).toContain('the fleet verdict decides')
expect(reference).toContain(
'ORCA orchestration worker-list --run <run_id> --include-remote --json'
)
expect(reference).toContain('reads `unverifiable` until you enumerate with `--include-remote`')
expect(reference).toContain('follow `page.nextCursor` with `--cursor <value>`')
})
it('requires positive evidence of exit before stop, abandon, retry, or release', () => {
const reference = squash(readReference('recovery-and-cleanup.md'))
expect(reference).toContain('Leave the wait only on positive proof the agent stopped')
expect(reference).toContain('`unverifiable` is always absence')
expect(reference).toContain('Absence never authorizes stop, abandon, retry, or release')
expect(reference).toContain(
'| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |'
)
})
it('owns the custom topology exception without claiming process ownership', () => {
const reference = readReference('low-level-topology.md')
expect(reference).toContain('only when `worker-start` cannot express')
expect(reference).toContain('terminal create --worktree active')
expect(reference).toContain('dispatch --task <task_id> --to <handle> --inject')
expect(reference).toContain('operator-created process unsupervised')
expect(squash(reference)).toContain('creates no supervised worker resource row')
expect(reference).toContain('Use `worker-start --terminal <handle>`')
expect(squash(reference)).toContain('never use it for an ownership handoff')
})
it('owns legacy labels, read-only degradation, exact recovery, and takeover', () => {
const reference = readReference('legacy-contract-migration.md')
expect(reference).toContain('[LEGACY COMPATIBILITY]')
expect(reference).toContain('[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]')
expect(reference).toContain('[LEGACY READ-ONLY]')
expect(squash(reference)).toContain(
'degrade to read-only inspection and never fall back to local execution'
)
expect(squash(reference)).toContain(
'must not spawn, write, signal, stop, switch, focus, split, or inject'
)
expect(reference).toContain('launcher status `75`')
expect(reference).toContain('run_legacy_local')
expect(reference).toContain('Recovered orchestration work from a contract update')
expect(reference).toContain('run-use --id <adopted_run_id> --takeover-legacy')
expect(reference).toContain(
'Never take over while the original coordinator is actively coordinating'
)
expect(workerTerminals).not.toContain('bare create opens a default shell')
expect(workerTerminals).not.toContain('ends with **one** agent tab')
expect(agentFirstExample).toBeDefined()
expect(agentFirstExample).not.toContain('orca terminal list')
expect(agentFirstExample).toContain('agentTerminalHandle')
expect(agentFirstExample).toContain('startupTerminal.handle')
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
expect(messaging).toContain('Continue with the replacement handle only')
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
})
})
describe('orchestration install stub', () => {
it('points at the version-matched guide and preserves the safe resolver', () => {
it('preserves the safe version-matched resolver and bounded old-binary fallback', () => {
const stub = readFileSync(stubPath, 'utf8')
expect(stub).toContain('discovery stub')
expect(stub).toContain('ORCA skills get orchestration')
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
expect(stub).toContain('ORCA_CLI_COMMAND')
expect(stub).toContain('orca-dev')
expect(stub).toContain('orca-ide')
expect(stub).toContain('GNOME Orca screen reader')
expect(squash(stub)).toContain('explicitly reports that `skills get` is an unknown command')
expect(stub).toContain('do not invent commands')
expect(stub).not.toMatch(/^orca /mu)
})
it('does not tell agents to mutate orchestration state before loading the guide', () => {
const preGuide = readFileSync(stubPath, 'utf8').split('## Load the full guide')[0]
expect(preGuide).not.toContain('orca orchestration task-create')
expect(preGuide).not.toContain('orca orchestration dispatch')
})
it('gives older binaries a bounded fallback instead of a dead end', () => {
const stub = readFileSync(stubPath, 'utf8').replace(/\s+/gu, ' ')
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
expect(stub).toContain('do not invent commands')
expect(stub).toContain('ask the user rather than guessing')
})
it('drops the changing command reference from the installable file', () => {
it('performs no orchestration mutation before loading the guide', () => {
const stub = readFileSync(stubPath, 'utf8')
const preGuide = stub.split('## Load the full guide')[0]
// Version-sensitive command detail lives in the binary-served guide now, not here.
expect(stub).not.toContain('check --wait')
expect(stub).not.toContain('dispatch-show')
expect(stub.length).toBeLessThan(readFileSync(guidePath, 'utf8').length)
})
it('keeps the routing frontmatter identical to the guide', () => {
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
expect(frontmatter(readFileSync(stubPath, 'utf8'))).toBe(
frontmatter(readFileSync(guidePath, 'utf8'))
)
expect(preGuide).not.toContain('orchestration task-create')
expect(preGuide).not.toContain('orchestration dispatch')
expect(frontmatter(stub)).toBe(frontmatter(readKernel()))
expect(stub.length).toBeLessThan(readKernel().length)
})
})
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const workflow = parse(
readFileSync(new URL('../../.github/workflows/packaged-browser-e2e.yml', import.meta.url), 'utf8')
)
const steps = workflow.jobs.compatibility.steps
describe('packaged browser compatibility lane', () => {
it('runs weekly and supports immutable manual or reusable revisions', () => {
expect(workflow.on.schedule).toHaveLength(1)
for (const trigger of ['workflow_dispatch', 'workflow_call']) {
expect(workflow.on[trigger].inputs.ref).toMatchObject({ type: 'string', required: false })
}
expect(steps[0].with.ref).toBe('${{ inputs.ref || github.sha }}')
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('verifies the pinned package before selecting the desktop executable', () => {
const download = steps.find((step) => step.name === 'Download pinned old release').run
expect(download).toContain('gh release download v1.4.188')
expect(download).toContain('hashlib.sha512(package.read_bytes())')
expect(download).toContain("extracted/'opt'/'Orca'/'orca-ide'")
expect(download).toContain('assert base64.')
expect(download).toContain('decode()==expected')
expect(download).toContain("['dpkg-deb'")
expect(download.indexOf('assert base64.')).toBeLessThan(download.indexOf("['dpkg-deb'"))
})
it('requires both directions three times and rejects silent skips', () => {
const run = steps.find((step) => step.name === 'Run both mixed-version directions')
expect(run.run).toContain('tests/e2e/packaged-mixed-version-browser-placement.spec.ts')
expect(run.run).toContain('--repeat-each=3')
expect(run.run).toContain('--retries=0')
expect(run.run).toContain('--reporter=list,json')
const verify = steps.find((step) => step.name === 'Require all six compatibility executions')
expect(verify.if).toBe('always()')
expect(verify.run).toBe(
`node config/scripts/verify-packaged-browser-participation.mjs ${run.env.PLAYWRIGHT_JSON_OUTPUT_FILE}`
)
expect(steps.at(-1).if).toBe('always()')
expect(steps.at(-1).with.path).toBe('test-results/')
})
})
@@ -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)
})
+12 -1
View File
@@ -140,6 +140,8 @@ const NATIVE_RUNTIME_PREFIXES = [
'config/scripts/ensure-native-runtime',
'config/scripts/rebuild-native-deps',
'config/scripts/node-pty-job-ownership',
'config/scripts/windows-process-tree-creation-time',
'config/scripts/windows-process-tree-gyp-rebuild',
'config/scripts/electron-builder-native-rebuild',
'config/patches/node-pty@',
'config/patches/@vscode__windows-process-tree'
@@ -213,13 +215,18 @@ 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/windows-process-table-native-addon.win32.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 +235,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 = [
+12 -15
View File
@@ -168,6 +168,7 @@ describe('PR E2E gate contract', () => {
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts"')
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"')
expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)')
@@ -375,14 +376,10 @@ describe('PR E2E gate contract', () => {
// that no runner names runs nowhere and still reports green — the silent skip this file
// exists to prevent. Asserting reachability rather than a literal keeps that true when
// the lanes move.
// Why these two are exempt: each needs something CI cannot give it, recorded in
// The remaining exemption needs performance validation before routine CI, recorded in
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
const unreachableSpecs = new Set([
'tests/e2e/ssh-docker-relay-perf.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
])
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
const unreachableSpecs = new Set(['tests/e2e/ssh-docker-relay-perf.spec.ts'])
// Why comments are stripped: the runner documents the exempt spec by name in a
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
// through the documentation.
@@ -636,13 +633,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 +674,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)
}
})
+43
View File
@@ -13,6 +13,38 @@ const NATIVE_IME_HARNESS =
/^(?:config\/scripts\/(?:run-terminal-ibus-hangul-e2e|terminal-ime-engagement-receipt)\.mjs$|tests\/e2e\/terminal-ime-(?:boundary-probe|byte-reader|engagement-receipt)\.ts$|tests\/e2e\/terminal-(?:ibus-hangul|hangul-terminating-digit|macos-2set-korean)-native\.spec\.ts$)/
export const PR_E2E_SOURCE_ROUTES = [
{
id: 'ssh.localhost-agent-hooks',
specs: ['tests/e2e/ssh-localhost.spec.ts'],
matches: (file) =>
isProductSource(file) &&
/^src\/(?:relay\/(?:agent-hook|relay-agent-hook-runtime|plugin-overlay)|main\/(?:agent-hooks\/|ssh\/ssh-relay-session\.ts$)|shared\/agent-hook)/.test(
file
)
},
{
id: 'browser-network.ssh-docker-route',
specs: ['tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'],
matches: (file) =>
file === 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' ||
/^tests\/e2e\/helpers\/docker-ssh-relay-(?:image|target)\.ts$/.test(file) ||
(isProductSource(file) &&
/^src\/main\/(?:browser\/(?:ssh-browser-network-execution-route|browser-network-deferred-socket|browser-network-execution-route|system-ssh-socks-client-socket)|ssh\/system-ssh-dynamic-forward-process)\.ts$/.test(
file
))
},
{
id: 'terminal.windows-wsl-launch-and-paste',
specs: [
'tests/e2e/golden-tab-bar-agent-launch.spec.ts',
'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts'
],
matches: (file) =>
isProductSource(file) &&
/^(?:config\/scripts\/(?:verify-wsl-e2e-participation|verify-playwright-participation)\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test(
file
)
},
{
id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
@@ -25,9 +57,11 @@ export const PR_E2E_SOURCE_ROUTES = [
id: 'ssh-terminal-source',
specs: [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-relay-stall-credential.spec.ts',
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
@@ -227,6 +261,13 @@ export function shouldRunReusablePrE2e(changedPaths) {
)
}
export function hasWslSourceChange(changedPaths) {
const route = PR_E2E_SOURCE_ROUTES.find(
(candidate) => candidate.id === 'terminal.windows-wsl-launch-and-paste'
)
return changedPaths.some(route.matches)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -238,6 +279,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
} else if (process.argv.includes('--reusable-workflow')) {
process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`)
} else if (process.argv.includes('--wsl-source')) {
process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`)
} else if (process.argv.includes('--native-ime-source')) {
process.stdout.write(`${hasNativeImeSourceChange(changedPaths)}\n`)
} else {
@@ -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))
@@ -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)
}
})
}
})
@@ -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')
@@ -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)
}
}
)
})
+64 -9
View File
@@ -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
@@ -521,6 +567,15 @@ function loadNativeModule(moduleName) {
}
return
}
if (moduleName === '@vscode/windows-process-tree') {
// The tarball prebuilt loads under Electron too -- the addon is N-API, so
// a bare require proves nothing about which source it was built from.
const { assertWindowsProcessTreeCreationTime } = projectRequire(
'./config/scripts/windows-process-tree-creation-time.cjs'
)
assertWindowsProcessTreeCreationTime({ module: projectRequire(moduleName) })
return
}
projectRequire(moduleName)
}
@@ -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 <baseline-redactor.ts>'
)
}
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))
@@ -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)
})
})
@@ -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 <base>: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 <baseline.ts>')
}
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
})
)
}
}
@@ -12,6 +12,8 @@ const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' },
@@ -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 `<cache>/nsis` — a directory no current
// app-builder-lib layout creates (real ones are `<cache>/nsis-3.0.4.1/nsis-3.0.4.1-<hash>/`
// and `<cache>/nsis@<toolset>/nsis-bundle-<v>-<hash>/`), 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-<version>` 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 <signed-elevate.exe>\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)
}
}
@@ -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
// `<cache>/<releaseName>/<archive basename>-<url hash>/`, and `releaseName` is
// `nsis-3.0.4.1` on the legacy bundle (`getBinFromUrl`) and `nsis@<toolset>` on the
// unified bundle. The release workflow searched `<cache>/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-<version>` 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-<hash>
// 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)
})
})
@@ -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 =
/<link\b(?=[^>]*\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'
? '<link '.repeat(Math.floor(size / 6))
: 'a'.repeat(size) + (shape === 'rel without href' ? ' rel:"icon"' : '')
assert.equal(extractIconHref(source), original(source))
const { beforeMs, afterMs } = measurePair(source)
results.push({
shape,
bytes: Buffer.byteLength(source),
beforeMs,
afterMs,
speedup: beforeMs / afterMs
})
}
}
console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2))
@@ -29,7 +29,7 @@ const result = spawnSync(
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'electron-headful',
'--workers=1',
...extraArgs
],
+11 -33
View File
@@ -6,6 +6,8 @@ const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_LOCAL_SSH_BROWSER: '1',
ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER: '1',
ORCA_E2E_WEB_CLIENT: '1'
}
@@ -31,33 +33,8 @@ if (runtime.status !== 0) {
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
// all. Recorded as a real gap, not as coverage living somewhere else.
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
// ssh-docker-bulk-open-freeze-repro.spec.ts — un-rotted and now measurable, and marked
// `test.fixme` because its oracle cannot gate. Absent from this list AND skipped, so the
// two cannot drift: it is also reachable from the changed-specs lane whenever the spec
// itself is edited, and a wall-clock oracle that fails there is worth no more than one
// that fails here.
// The rot (#16764) is fixed: the stale call sites are repaired, it connects after session
// restore instead of before, and readiness keys on the repeating flood marker rather than
// a one-shot READY line the flood buries within ~16ms. It runs end to end and prints a
// measurement instead of dying on a call site.
// What it is NOT is portable. Three runs of the same measurement path:
// developer workstation: hiddenFlood 2.1ms bulkOpen 41.5ms interaction 53.6ms
// GitHub ubuntu runner A: hiddenFlood 1.5ms bulkOpen 2575.6ms interaction 3464.2ms
// GitHub ubuntu runner B: hiddenFlood 0.2ms bulkOpen 397.4ms interaction 3386.7ms
// bulkOpen swings 6.5x between two CI runs of the same code, so a fixed threshold on it is
// a coin flip; interaction sits stably ~64x over the workstation figure because it times a
// view remount, not the renderer freeze the issue reports, and only shares the budget
// constant because both are milliseconds. Every failure so far is the soft budget; hard
// has never tripped, and the relay was still streaming each time — the budget failed, not
// the product. Same rule as ssh-docker-relay-perf above. Gating needs a distribution
// first, then a host-relative oracle; a bigger constant, or a ratio picked from three
// samples, is the same arbitrary number in different clothes.
// COVERAGE GAP, recorded as such: 5 simultaneously flooding SSH panes exercise writer
// saturation, ACK/credit accounting and per-pane polling together, and nothing else covers
// that combination. Flip `test.fixme` back to `test` to run it. Tracked in
// stablyai/orca#16764.
// The bulk-open frame probe runs headed: headless Linux compositing schedules idle RAFs
// roughly 1s apart, so it cannot measure foreground interaction against the same budget.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
@@ -69,27 +46,28 @@ if (runtime.status !== 0) {
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
// has the exact wiring to flip it, and the gate contract asserts the current state.
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
// - Two specs are gated on env vars no workflow sets, so they run nowhere
// and are not Docker-gated, which puts them outside this file's contract:
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
// The nested-runtime runner remains unused by CI.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/local-ssh-browser-routing.spec.ts',
'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-relay-stall-credential.spec.ts',
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
@@ -199,7 +199,8 @@ async function runInsideSession(evidenceDir) {
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts'
],
{
cwd: projectDir,
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { stripTypeScriptTypes } from 'node:module'
import { performance } from 'node:perf_hooks'
import { blankStringContents as after } from '../../src/shared/source-scan/source-tree-scan.ts'
const ref = process.argv[2]
if (!ref) {
throw new Error('Usage: node config/scripts/source-string-blanking-benchmark.mjs <baseline-ref>')
}
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
)
)
@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse } from 'yaml'
import { expect, it } from 'vitest'
import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
const root = resolve(import.meta.dirname, '../..')
const workflow = parse(readFileSync(join(root, '.github/workflows/e2e.yml'), 'utf8'))
const runner = readFileSync(join(root, 'config/scripts/run-ssh-docker-e2e.mjs'), 'utf8')
it('routes SSH browser specs to a lane that enables their opt-ins', () => {
const changedRun = workflow.jobs['changed-e2e'].steps.find(
(step) => step.name === 'Run changed E2E specs'
)
for (const [spec, flag] of [
['tests/e2e/local-ssh-browser-routing.spec.ts', 'ORCA_E2E_LOCAL_SSH_BROWSER'],
[
'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts',
'ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER'
]
]) {
expect(runner).toContain(`'${spec}'`)
expect(runner).toContain(`${flag}: '1'`)
expect(workflow.jobs['ssh-docker-watcher-isolation'].if).toContain(spec)
expect(changedRun.run).toContain(`. != "${spec}"`)
}
})
it('executes both Docker network routes in a Node job with their opt-in enabled', () => {
const spec = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'
const job = workflow.jobs['ssh-browser-network-route']
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const run = job.steps.find(
(step) => step.name === 'Run Docker SSH browser network route journeys'
)
expect(job['runs-on']).toBe('ubuntu-latest')
expect(job.if).toContain("inputs.test_files == ''")
expect(job.if).toContain(spec)
expect(install.with['native-runtime']).toBe('node')
expect(run.env.ORCA_RUN_DOCKER_SSH_BROWSER_E2E).toBe('1')
expect(run.run).toContain(`vitest run --config config/vitest.config.ts ${spec}`)
expect(run['continue-on-error']).toBeUndefined()
expect(
workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run
).toContain(`. != "${spec}"`)
for (const changed of [
spec,
'src/main/browser/ssh-browser-network-execution-route.ts',
'src/main/browser/browser-network-deferred-socket.ts',
'src/main/browser/browser-network-execution-route.ts',
'src/main/browser/system-ssh-socks-client-socket.ts',
'src/main/ssh/system-ssh-dynamic-forward-process.ts',
'tests/e2e/helpers/docker-ssh-relay-target.ts',
'tests/e2e/helpers/docker-ssh-relay-image.ts'
]) {
expect(selectPrE2eSpecs([changed])).toContain(spec)
}
expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec)
expect(selectPrE2eSpecs(['tests/e2e/helpers/docker-ssh-relay-terminal-tabs.ts'])).not.toContain(
spec
)
})
@@ -0,0 +1,52 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parse } from 'yaml'
import { expect, it } from 'vitest'
import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
const workflow = parse(
readFileSync(resolve(import.meta.dirname, '../../.github/workflows/e2e.yml'), 'utf8')
)
it('gives the localhost SSH journey its same-filesystem server and agent prerequisite', () => {
const spec = 'tests/e2e/ssh-localhost.spec.ts'
const job = workflow.jobs['ssh-localhost']
expect(job.if).toContain("inputs.test_files == ''")
expect(job.if).toContain(spec)
expect(job['runs-on']).toBe('ubuntu-latest')
expect(job.needs).toEqual(['build', 'prepare-native-cache'])
const setup = job.steps.find((step) => step.name === 'Start isolated localhost SSH server')
expect(setup.run).toContain('ListenAddress 127.0.0.1')
expect(setup.run).toContain('PasswordAuthentication no')
expect(setup.run).toContain('UsePAM yes')
expect(setup.run).toContain('mkdir -p "$HOME/.pi/agent"')
for (const key of ['ORCA_E2E_SSH_PORT', 'ORCA_E2E_SSH_USER', 'ORCA_E2E_SSH_IDENTITY_FILE']) {
expect(setup.run).toContain(key)
}
const run = job.steps.find((step) => step.name === 'Run localhost SSH terminal and hook journey')
expect(run.env.ORCA_E2E_SSH_LOCALHOST).toBe('1')
expect(run.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS).toBe('1')
expect(run.run).toContain(spec)
expect(run.run).toContain('--project=electron-headless')
expect(run.run).not.toContain('--retries')
expect(run['continue-on-error']).toBeUndefined()
expect(
workflow.jobs['changed-e2e'].steps.find((step) => step.name === 'Run changed E2E specs').run
).toContain(`. != "${spec}"`)
})
it('selects the localhost journey for its remote hook authorities', () => {
const spec = 'tests/e2e/ssh-localhost.spec.ts'
for (const file of [
'src/relay/relay-agent-hook-runtime.ts',
'src/relay/agent-hook-server.ts',
'src/relay/plugin-overlay.ts',
'src/main/agent-hooks/server.ts',
'src/main/ssh/ssh-relay-session.ts',
'src/shared/agent-hook-relay.ts'
]) {
expect(existsSync(resolve(import.meta.dirname, '../..', file)), file).toBe(true)
expect(selectPrE2eSpecs([file])).toContain(spec)
}
expect(selectPrE2eSpecs(['src/renderer/src/components/Unrelated.tsx'])).not.toContain(spec)
})
@@ -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) {
@@ -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'])
})
@@ -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'],
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs'
export const PACKAGED_BROWSER_TEST_TITLES = [
'keeps an old packaged client on the current server-hosted path',
'keeps a current client on an old packaged server-hosted path'
]
export function verifyPackagedBrowserParticipation(report) {
verifyPlaywrightParticipation(report, {
titles: PACKAGED_BROWSER_TEST_TITLES,
label: 'Packaged browser'
})
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
verifyPackagedBrowserParticipation(JSON.parse(readFileSync(process.argv[2], 'utf8')))
console.log('Both packaged browser directions passed three times without skips or retries.')
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
verifyPackagedBrowserParticipation,
PACKAGED_BROWSER_TEST_TITLES
} from './verify-packaged-browser-participation.mjs'
function report() {
return {
stats: { expected: 6, skipped: 0, unexpected: 0, flaky: 0 },
suites: [
{
suites: [
{
specs: PACKAGED_BROWSER_TEST_TITLES.map((title) => ({
title,
tests: Array.from({ length: 3 }, () => ({
expectedStatus: 'passed',
results: [{ status: 'passed' }]
}))
}))
}
]
}
]
}
}
describe('Packaged browser participation', () => {
it('accepts both named scenarios executed three times', () => {
expect(() => verifyPackagedBrowserParticipation(report())).not.toThrow()
})
it.each(['skipped', 'unexpected', 'flaky'])('rejects a nonzero %s result', (key) => {
const value = report()
value.stats[key] = 1
expect(() => verifyPackagedBrowserParticipation(value)).toThrow('participation failed')
})
it('rejects missing scenarios even when aggregate counts claim six passes', () => {
const value = report()
value.suites[0].suites[0].specs.pop()
expect(() => verifyPackagedBrowserParticipation(value)).toThrow('requires three executions')
})
it('rejects an unrelated scenario substituted for an expected scenario', () => {
const value = report()
value.suites[0].suites[0].specs[0].title = 'native shell passes'
expect(() => verifyPackagedBrowserParticipation(value)).toThrow(
'Unexpected Packaged browser scenario'
)
})
it('rejects a pass obtained after a failed attempt', () => {
const value = report()
value.suites[0].suites[0].specs[0].tests[0].results.unshift({ status: 'failed' })
expect(() => verifyPackagedBrowserParticipation(value)).toThrow('without retries')
})
it('rejects missing report content', () => {
expect(() => verifyPackagedBrowserParticipation({})).toThrow('participation failed')
})
})
@@ -0,0 +1,42 @@
export function verifyPlaywrightParticipation(report, { titles, label, repetitions = 3 }) {
const stats = report?.stats
if (
!stats ||
stats.expected !== titles.length * repetitions ||
stats.skipped !== 0 ||
stats.unexpected !== 0 ||
stats.flaky !== 0 ||
report.errors?.length
) {
throw new Error(`${label} participation failed: ${JSON.stringify(stats)}`)
}
const counts = new Map(titles.map((title) => [title, 0]))
const visit = (suites) => {
for (const suite of suites ?? []) {
for (const spec of suite.specs ?? []) {
if (!counts.has(spec.title)) {
throw new Error(`Unexpected ${label} scenario: ${spec.title}`)
}
for (const test of spec.tests ?? []) {
if (
test.expectedStatus !== 'passed' ||
test.results?.length !== 1 ||
test.results[0].status !== 'passed'
) {
throw new Error(`${label} scenario did not pass without retries: ${spec.title}`)
}
counts.set(spec.title, counts.get(spec.title) + 1)
}
}
visit(suite.suites)
}
}
visit(report.suites)
for (const [title, count] of counts) {
if (count !== repetitions) {
throw new Error(
`${label} scenario requires ${repetitions === 3 ? 'three' : repetitions} executions: ${title} (${count})`
)
}
}
}
@@ -0,0 +1,18 @@
import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
export const WSL_TEST_TITLES = [
'tab-bar + menu launches an agent inside WSL @tab-bar-agent-launch-golden',
'WSL terminal keyboard paste preserves Linux shell content with one PTY owner',
'existing WSL terminal keeps paste runtime after default shell changes'
]
export function verifyWslParticipation(report) {
verifyPlaywrightParticipation(report, { titles: WSL_TEST_TITLES, label: 'WSL' })
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
verifyWslParticipation(JSON.parse(readFileSync(process.argv[2], 'utf8')))
console.log('All three WSL scenarios passed three times without skips or retries.')
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { verifyWslParticipation, WSL_TEST_TITLES } from './verify-wsl-e2e-participation.mjs'
function report() {
return {
stats: { expected: 9, skipped: 0, unexpected: 0, flaky: 0 },
suites: [
{
suites: [
{
specs: WSL_TEST_TITLES.map((title) => ({
title,
tests: Array.from({ length: 3 }, () => ({
expectedStatus: 'passed',
results: [{ status: 'passed' }]
}))
}))
}
]
}
]
}
}
describe('WSL participation', () => {
it('accepts all three named scenarios executed three times', () => {
expect(() => verifyWslParticipation(report())).not.toThrow()
})
it.each(['skipped', 'unexpected', 'flaky'])('rejects a nonzero %s result', (key) => {
const value = report()
value.stats[key] = 1
expect(() => verifyWslParticipation(value)).toThrow('participation failed')
})
it('rejects missing scenarios even when aggregate counts claim nine passes', () => {
const value = report()
value.suites[0].suites[0].specs.pop()
expect(() => verifyWslParticipation(value)).toThrow('requires three executions')
})
it('rejects an unrelated scenario substituted for an expected scenario', () => {
const value = report()
value.suites[0].suites[0].specs[0].title = 'native shell passes'
expect(() => verifyWslParticipation(value)).toThrow('Unexpected WSL scenario')
})
it('rejects a pass obtained after a failed attempt', () => {
const value = report()
value.suites[0].suites[0].specs[0].tests[0].results.unshift({ status: 'failed' })
expect(() => verifyWslParticipation(value)).toThrow('without retries')
})
it('rejects missing report content', () => {
expect(() => verifyWslParticipation({})).toThrow('participation failed')
})
})
@@ -0,0 +1,42 @@
'use strict'
/**
* Prove the COMPILED addon understands `CREATIONTIME`, not just the patched JS.
*
* Unlike node-pty, this package ships a prebuilt `.node` at the same
* `build/Release/` path node-gyp writes to, so neither a load nor a path check
* can tell a stale prebuilt from a source build. pnpm patches the source tree
* and leaves that prebuilt in place, which is how `ProcessDataFlag.CreationTime`
* came to exist in `lib/index.js` on a binary that ignores flag 4 -- the gate
* read true and every row came back without `creationTimeMs`.
*
* `supportedProcessDataFlags` is exported by the patched `addon.cc`, so its
* presence is the binary's own answer. Shared by the Node and Electron probes
* the way `node-pty-job-ownership.cjs` is.
*/
/** `ProcessDataFlags::CREATIONTIME` in src/process.h. */
const CREATION_TIME_FLAG = 4
function assertWindowsProcessTreeCreationTime({ module, platform = process.platform }) {
if (platform !== 'win32') {
return
}
const supported = module?.supportedProcessDataFlags
if (typeof supported === 'number' && (supported & CREATION_TIME_FLAG) !== 0) {
return
}
throw new Error(
[
'@vscode/windows-process-tree does not report CreationTime support',
`(supportedProcessDataFlags=${String(supported)}).`,
'That is the tarball prebuilt, not a build of the patched source, so every',
'process row comes back without creationTimeMs: Windows descendant exit',
'verification cannot identify a PID and structured Claude/Codex chat runs',
'with an unprovable child-tree reaper.',
'Rebuild it from source so config/patches/@vscode__windows-process-tree@0.8.0.patch applies.'
].join(' ')
)
}
module.exports = { assertWindowsProcessTreeCreationTime, CREATION_TIME_FLAG }
@@ -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
@@ -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')
})
})
@@ -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)]
})
})
@@ -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 `<installer basename>__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
}
@@ -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')
})
})
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { hasWslSourceChange, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
const read = (path) => readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8')
describe('real WSL terminal lane', () => {
it.each([
'config/scripts/verify-wsl-e2e-participation.mjs',
'config/scripts/verify-playwright-participation.mjs',
'src/main/wsl-availability.ts',
'src/main/wsl/wsl-runner.ts',
'src/main/pty/wsl-orca-env.ts',
'src/shared/wsl-login-shell-command.ts',
'src/shared/windows-terminal-shell.ts',
'tests/e2e/helpers/wsl-golden-stub-agent.ts',
'tests/e2e/golden-tab-bar-agent-launch.spec.ts',
'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts',
'.github/actions/setup-wsl-test-runtime/setup.ps1',
'.github/workflows/windows-wsl-e2e.yml'
])('routes %s to both WSL sentinels', (path) => {
expect(hasWslSourceChange([path])).toBe(true)
expect(selectPrE2eSpecs([path])).toEqual(
expect.arrayContaining([
'tests/e2e/golden-tab-bar-agent-launch.spec.ts',
'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts'
])
)
})
it.each([
'docs/reference/wsl-command-execution.md',
'src/main/wsl-availability.test.ts',
'src/main/ssh/connection.ts'
])('excludes unrelated or unit-only change %s', (path) => {
expect(hasWslSourceChange([path])).toBe(false)
})
it('runs the reusable lane at the immutable PR head', () => {
const pr = parse(read('.github/workflows/pr.yml'))
expect(pr.jobs.windows_wsl.if).toBe("needs.code_paths.outputs.wsl_source_changed == 'true'")
expect(pr.jobs.windows_wsl.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
const detector = pr.jobs['code_paths'].steps.find(
(step) => step.name === 'Filter changed E2E specs'
)
expect(detector.run).toContain(
'WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR'
)
expect(detector.run).toContain(
'"$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source'
)
const workflow = parse(read('.github/workflows/windows-wsl-e2e.yml'))
const steps = workflow.jobs['wsl-terminal'].steps
expect(steps[0].with.ref).toBe('${{ inputs.ref || github.sha }}')
expect(steps.some((step) => step.uses === './.github/actions/setup-wsl-test-runtime')).toBe(
true
)
const exercise = steps.find((step) => step.name === 'Exercise real WSL launch and paste')
expect(exercise.run.split(/\s+/).filter((arg) => arg.startsWith('--repeat-each='))).toEqual([
'--repeat-each=3'
])
expect(exercise.run).toContain('--grep "WSL"')
const receipt = steps.find((step) => step.name === 'Require all nine WSL executions')
expect(receipt.if).toBe('always()')
expect(receipt.run).toBe(
'node config/scripts/verify-wsl-e2e-participation.mjs test-results/wsl-results.json'
)
})
})
+1
View File
@@ -32,6 +32,7 @@
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-record-reader.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
+1
View File
@@ -11,6 +11,7 @@ const contracts = [
'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts',
'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts',
'src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts',
'src/renderer/src/store/store-identity-churn-probe.test.ts',
'config/scripts/app-store-performance-plugin.test.mjs',
'config/scripts/quadratic-buffer-concat-plugin.test.mjs',
'config/scripts/sort-comparator-performance-plugin.test.mjs'
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 41m">
<title>downloads: 41m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 42m">
<title>downloads: 42m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">41m</text>
<text x="90" y="14">41m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">42m</text>
<text x="90" y="14">42m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.47 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
- **Android:** [APK 0.0.48 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
- **Android:** [Baixar APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk)
---
@@ -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 "<caret-escaped argv>"`. 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 <script> <args…>` directly: no cmd.exe in the tree, and no
caret escaping at all.
## What resolution changes
Only `runProcess` / `spawnProcess` callers. Two things people expect it to
cover, and it does not:
- **The interactive terminal.** `src/main/daemon/pty-subprocess/native-pty-spawn.ts`
calls `pty.spawn` directly, so typing `codex` in an Orca terminal is
completely unaffected.
- **Orca's own hook wrappers** (`codex-hook.cmd` and friends). These are batch
files Orca writes, matching none of the generator shapes, so they keep the
cmd.exe path. They are addressable — we generate them — but not by this
module.
## Adding a shape
Four shapes are recognised, each transcribed verbatim from a real install into
`src/shared/child-process/__fixtures__/windows-cmd-shim-bodies.ts`. If you add a
fifth, add its real body there too. A shape guessed from documentation is not
evidence.
The rule for the parser is all-or-nothing: the whole canonicalised body must
match end to end, and anything unrecognised returns null and keeps the cmd.exe
path. **A mis-resolution silently runs the wrong program or drops arguments,
which is far worse than an EDR alert** — when in doubt, refuse.
Resolution also refuses a captured path that is absolute, drive-relative
(`D:evil.js` — `win32.isAbsolute` says false, but `win32.resolve` leaves the
shim directory), or contains `% ^ & | < > " :` or a line break; a script or
target that is not on disk; an interpreter-less target that is not `.exe`/`.com`;
and a program path that is not absolute.
Refusing every `:` cannot cause a false refusal. Windows reserves the character
within a path segment, so a relative path cannot contain one — the only
spellings that can are drive-qualified, an alternate data stream (`a.js:zone`),
or a `\\?\` device path, and the last is already refused as absolute.
## Kill switch
Set **`ORCA_DISABLE_CMD_SHIM_RESOLUTION`** to any non-empty value in the
environment a child is spawned with, and every `.cmd` goes back through
`cmd.exe /c` unchanged. It is read from the spawn's own environment, so
exporting it before launching Orca disables resolution process-wide.
Use it to confirm a suspected mis-resolution: run the failing operation with and
without it. Identical behaviour means resolution is not the cause. If it is,
report the shim's body — the parser is only allowed to recognise shapes we have
seen for real.
## Behaviour that changes, deliberately
A resolved shim is not merely a quieter spelling of the cmd.exe path. Two limits
of `cmd.exe` disappear with it:
- An argument containing `\r`/`\n` was rejected outright, because cmd ends its
command at a raw line break whatever the quote state. Multi-line agent prompts
now work.
- A command line over 8191 characters returned `The command line is too long.`
Long prompts now work.
Both are improvements, but they are behaviour changes: an unresolved shim still
hits both limits, so a caller must not assume every `.cmd` accepts them.
@@ -0,0 +1,118 @@
# Windows daemon-host relocation
On Windows the terminal daemon does not run from the install directory. Before it forks the
daemon, Orca materializes a trimmed copy of its own runtime under
`%LOCALAPPDATA%\Orca\daemon-host\<app version>\` and forks the daemon from there
(`src/main/daemon/daemon-host-relocation.ts`). This is what keeps live terminals alive across an
auto-update and across a crash of the main process.
Read this before changing the copy plan, the host exe name, the LOCALAPPDATA layout, or
`config/nsis/orca-installer-hooks.nsh`.
## What the relocation actually escapes
The killer is **electron-builder's process sweep, matched on image path** — not file deletion.
Windows will not delete a running image, so `RMDir /r "$INSTDIR"` cannot end the daemon on its own.
In app-builder-lib's `allowOnlyOneInstallerInstance.nsh`, `FIND_PROCESS` / `KILL_PROCESS` have two
branches:
| Branch | Condition | Selector |
| -------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Primary | `powershell.exe` runs, `Get-CimInstance` resolves, and `Get-ExecutionPolicy -Scope Process` is not `Restricted` | `Win32_Process` where `$_.Path.StartsWith('$INSTDIR', 'CurrentCultureIgnoreCase')` — **path-scoped** |
| Fallback | otherwise | per-user: `taskkill /F /IM "<AppName>.exe" /FI "PID ne $pid" /FI "USERNAME eq %USERNAME%"`; per-machine: the same without the username filter — **image-name-scoped** |
The probe reads the **process** scope, not the effective policy, and Group Policy writes
`MachinePolicy`/`UserPolicy` — so a GPO-managed host whose effective policy is `Restricted` still
exits 0 and takes the primary branch. The fallback is reached only when `powershell.exe` is absent,
`Get-CimInstance` does not resolve, PowerShell is blocked outright (WDAC/AppLocker, Server Core), or
an inherited `PSExecutionPolicyPreference=Restricted` is in the environment.
So on essentially every machine the sweep is path-scoped, and a daemon whose image lives under
`%LOCALAPPDATA%` is out of range regardless of what the file is called. **Survival is a property of
the path.** The name only matters on the fallback branch.
## Why the exe is copied verbatim (and not renamed)
The host exe keeps the app exe's own file name (`daemonHostExeName()` returns
`basename(process.execPath)`), so the relocated image is a byte-for-byte copy of the app binary
under its original name.
An earlier revision copied it as `orca-terminal-daemon.exe` specifically so the fallback
`taskkill /IM Orca.exe` could not match. That bought survival on the rare no-PowerShell host and
cost a textbook defence-evasion signature: _a process copies its own image into a user-writable
directory under a different name so a kill-by-image-name cannot match it, then runs detached and
survives the installer._ Microsoft Defender for Endpoint flagged it as MITRE **T1036
(Masquerading)**, and — because it is the process every other flagged action is attributed to — it
acted as a reputation multiplier on unrelated findings. No VS Code fork does this.
Trading the fallback branch for the name is the right trade:
- On the primary branch nothing changes: the daemon still survives the update.
- On the fallback branch the daemon is killed with the app and terminals **cold-restore** on
relaunch. That is the documented pre-relocation behaviour, a first-class outcome the update
harness already asserts (`--expect cold-restore`), not a failure.
- Relocation is fail-open end to end anyway: any materialization failure returns `null` and the
caller forks the install-dir host.
One new failure mode comes with it, on the fallback branch only. The daemon now matches
`FIND_PROCESS` under the app's image name, so it enters electron-builder's retry loop
(`allowOnlyOneInstallerInstance.nsh:136-141`). If the `taskkill` there fails to end it — an elevated
or otherwise unkillable host — the loop reaches `MessageBox ... /SD IDCANCEL` and `Quit`s, aborting a
silent update rather than completing it. Under the old distinct name the daemon was invisible to
that loop. Low probability (fallback branch _and_ an unkillable daemon), but it is a real new path.
What this does **not** buy. Two things bound the win honestly:
- The strongest T1036 indicator is a PE-resource-vs-disk-name mismatch, and it was **never firing**:
the shipped binary's `OriginalFilename` is empty (only `InternalName = Orca` is set), so there was
no embedded name for the old disk name to contradict.
- The remaining behaviour — a signed app copying its own ~225 MB image into user-writable
`%LOCALAPPDATA%` and running it detached under `ELECTRON_RUN_AS_NODE=1` — is still execution from
a non-standard user-writable location, which maps to **T1036.005** and is a standard heuristic on
its own.
So this removes a real but partial signal. Expect the score to drop; do not expect the process to
stop being scored.
## Options that were rejected
| Option | Why not |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Materialize the tree from the NSIS installer | The daemon host is ~246 MB. Writing it at install time doubles install footprint and lengthens the window in which the app is down during a silent update. Worse, on a per-machine install (`INSTALL_MODE_PER_ALL_USERS`) the installer runs as the installing admin, so `$LOCALAPPDATA` is the wrong user's — every other user still needs the runtime path, which means the runtime self-copy stays in the product and the signal is only made rarer. |
| Ship a second signed `orca-terminal-daemon.exe` in the installer | `Orca.exe` is 235,555,328 bytes (224.6 MiB). electron-builder's NSIS uses solid LZMA with a 64 MB dictionary, so a second copy 224 MB downstream does not dedupe; the compressed installer grows by roughly a whole compressed Electron binary, paid by every user on every update download. It also does not remove the runtime copy — the helper still has to reach `%LOCALAPPDATA%` to escape the sweep — so it buys the same signal reduction as the verbatim copy at a large download cost. |
| Override `customCheckAppRunning` to force a path-scoped kill on both branches | Cheap to write (~6 lines: `!include "getProcessInfo.nsh"`, `Var pid`, and a macro that pins `IsPowerShellAvailable`, reusing upstream's dialog, retry loop and elevated handling) — but wrong at any size. Forcing the PowerShell branch on a host where PowerShell is genuinely absent makes `FIND_PROCESS` and `KILL_PROCESS` silently no-op, so the installer proceeds with the **real app** still running and its files in use. That is a worse outcome than the cold restore it would prevent, so this is not worth doing ever, not merely not now. |
| Hardlink instead of copy | Avoids the 246 MB entirely and is not a "copy" at all, but is NTFS-and-same-volume-only and introduces fresh failure modes (link counts, AV interception, cross-volume installs). Worth revisiting deliberately, not as part of a signal fix. |
## Invariants to preserve
- The host exe name is **derived from `process.execPath`**, never a literal. A future
`executableName` or dev-channel rename must follow automatically; pinning a name of our own is
how the mismatch creeps back.
- The daemon is identified by **PID and command line**, never by image name — in the product
(`daemon-pid-file-parse`, `daemon-process-inspection`) and in the harness
(`tests/tools/win-update-e2e/daemon-processes.mjs`). Nothing may start matching on the exe name.
- `config/nsis/orca-installer-hooks.nsh` kills the daemon by image name. That now also matches the
app's own exe, which is correct on a genuine uninstall — the product is being removed — but its
`${isUpdated}` guard must stay: electron-builder runs the uninstaller during every update's
`uninstallOldVersion`, and killing the daemon there defeats the whole feature. The legacy
`orca-terminal-daemon.exe` name stays in the macro to reap hosts left by older builds.
- `LOCAL_HOST_ROOT_NAME` in `daemon-host-relocation.ts` and the path in the uninstall macro are the
same directory. Change both together.
## Verifying a change
Unit coverage lives in `src/main/daemon/daemon-host-relocation.test.ts` (copy plan, verbatim
naming, marker/atomic publish, fail-open, prune veto). Nothing in unit tests can prove survival, so
any change to this file or to the NSIS macro needs the packaged harnesses:
- `.github/workflows/win-update-survival-e2e.yml` — builds an installer from the branch and updates
it over itself with `--expect survival`. The primary proof.
- `.github/workflows/win-crash-survival-e2e.yml` — proves the daemon survives a main-process crash.
- `.github/workflows/windows-terminal-restart-e2e.yml` — terminal restart behaviour.
- `.github/workflows/win-update-e2e.yml` — release-tag-to-release-tag update, both `survival` and
`cold-restore` profiles.
All four are `workflow_dispatch`-only (the two update workflows also carry a push trigger pinned to
one historical feature branch), so they must be dispatched by hand against this branch before
merging a change here — which requires the workflow files to already exist on `main`.
+74 -48
View File
@@ -13,7 +13,7 @@ two escalated to multi-stage incidents carrying ATT&CK tactic mappings
The framing this document keeps throughout, because both halves matter:
> **Defender is not malfunctioning. It is describing the code accurately.** Orca
> really does copy its own signed image under a different name, really does read
> really does copy its own signed image under a different name, really did read
> every process's memory on a timer, really does run base64-encoded PowerShell
> with the execution policy bypassed, and really does take screenshots and
> synthesise input from a runtime-compiled assembly. Each of those is a
@@ -50,33 +50,49 @@ and `orca-terminal-daemon.exe` report `Valid CN=SignPath Foundation`.
## The behaviours, and why each one exists
### The daemon runs from a renamed copy of our own image
### The daemon runs from a copy of our own image
`src/main/daemon/daemon-host-relocation.ts` copies the Electron runtime into
`%LOCALAPPDATA%\Orca\daemon-host\<version>\` and renames `Orca.exe` to
`orca-terminal-daemon.exe`. The comment on `DAEMON_HOST_EXE_NAME` states the
reason without varnish: _"so the NSIS updater's `taskkill /IM Orca.exe` can't
match it."_
`%LOCALAPPDATA%\Orca\daemon-host\<version>\` and forks the terminal daemon from
there.
It exists because the NSIS installer deletes the old install directory and force-
kills every process imaged under it. Without relocation, an auto-update kills the
terminal daemon and every live terminal with it. The copy is a run-as-node
`Orca.exe` rather than `node.exe` so there is no console flash and asar still
resolves; `config/nsis/daemon-host-uninstall.nsh` reaps it on a real uninstall
resolves; `config/nsis/orca-installer-hooks.nsh` reaps it on a real uninstall
(guarded by `${isUpdated}` so an update's `uninstallOldVersion` never fires it).
**How an EDR reads it: MITRE T1036, masquerading.** A signed executable copied
out of the install directory into `%LOCALAPPDATA%` under a different name, which
then spawns shells, matches the textbook description closely enough that no
behavioural engine can be expected to score it low.
**At the time of these incidents the copy was also renamed** to
`orca-terminal-daemon.exe`, the image name every incident here reports, and
`DAEMON_HOST_EXE_NAME`'s comment stated the reason without varnish: _"so the NSIS
updater's `taskkill /IM Orca.exe` can't match it."_ The rename has since been
removed; the copy now keeps the app exe's own file name, because the updater's
kill sweep is path-scoped on every host that has PowerShell and the rename only
ever bought the no-PowerShell fallback. See
[`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md).
**How an EDR reads it: MITRE T1036, masquerading** — and, for what remains,
**T1036.005**. A signed executable copied out of the install directory into
`%LOCALAPPDATA%` under a different name, which then spawns shells, matches the
textbook description closely enough that no behavioural engine can be expected to
score it low. Dropping the rename removes that literal indicator but not the
underlying shape: execution from a non-standard user-writable location is scored
on its own. Note also that the strongest form of the T1036 signal was never
present here — the shipped binary's `OriginalFilename` is empty, so there was no
embedded name for the old disk name to contradict.
### Every process gets a handle, on a timer
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under
**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid
and name come out of the snapshot itself and open nothing. `CommandLine` is what
opens a handle: the addon calls `GetProcessCommandLine` per process, which opens
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one
of **two** flag sets: identity (`None | CreationTime`) for callers that read only
pid, ppid and name, and detailed (`+ CommandLine`) for callers that match on a
command line. pid, ppid and name come out of the snapshot itself and open
nothing, so an identity scan opens nothing at all. `CommandLine` is what opens a
handle: the addon calls `GetProcessCommandLine` per process, which opens
`PROCESS_QUERY_LIMITED_INFORMATION` — the same right Task Manager takes — and
asks the kernel for the string. Upstream it opened
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walked the PEB with three
`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored
`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches).
@@ -84,8 +100,9 @@ opens a handle: the addon calls `GetProcessCommandLine` per process, which opens
`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION |
PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose
result no caller read (`src/process.cc:47-63`). Dropping it halves the handles
opened per snapshot. It does not remove the remote memory read, because the
command line still performs one.
opened per snapshot. On its own it removed no memory read — both handles carried
`PROCESS_VM_READ` at the time — so it composes with the patch below rather than
substituting for it.
It exists because seven independent readers used to fork `powershell.exe` for a
`Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell
@@ -98,41 +115,49 @@ panes multiplied it (#15036). The native snapshot answers the same question in
See
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
Asking for fewer fields is cheaper, and the module now asks for the smallest set
that still answers every caller. There is **no** per-flag-set cache split: one
TTL-cached snapshot serves everyone, deliberately, because a split would restore
the per-pane fan-out the cache exists to remove — a 32-wide teardown has to
collapse into one scan. So the cheap identity-only read is not something any
caller can select; every read pays for `CommandLine`. An earlier revision of this
file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492
processes. That design is not in the tree and those numbers describe no code
path here; the figures that do apply are the module's own, in
Asking for fewer fields is cheaper, and each caller now asks for the smallest set
that answers it. There are exactly **two** TTL-cached snapshots, one per flag
set, never one per caller: the fan-out the cache exists to remove is one scan per
_caller_, and each reader still serves every caller wanting its flag set, so a
32-wide teardown still collapses into one scan of each. Teardown identity and the
owner probe select the identity set and therefore open no handles; the per-pane
foreground tracker genuinely needs a command line and still pays for one. A third
cache would need a third flag set, not a third caller. Measured at 492 processes,
p50: identity 6.3 ms, detailed 12.3 ms — see
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
**How an EDR reads it:** a cross-process handle plus a remote memory read against
**How an EDR read it:** a cross-process handle plus a remote memory read against
every process on the box, repeating on a cadence, is the read half of the
telemetry that credential dumping and process injection produce. MDE surfaced it
as "suspicious memory activity".
**That signal is still present.** An earlier revision of this file claimed the
command line "now comes from the kernel" through `NtQueryInformationProcess`'s
`ProcessCommandLineInformation` class, needing only
`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from
the compiled addon. None of that is true of the code we ship.
`process_commandline.cc` calls `NtQueryInformationProcess` with
`ProcessBasicInformation` only — to locate the PEB — and then issues three
`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the
`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an
import table, and no such assertion would pass.
**The memory read is gone.** A fourth hunk in
`config/patches/@vscode__windows-process-tree@0.8.0.patch` has
`GetProcessCommandLine` call `NtQueryInformationProcess` with
`ProcessCommandLineInformation` (class 60, Windows 8.1+; Electron's floor is
Windows 10), which returns a `UNICODE_STRING` the kernel builds and needs only
`PROCESS_QUERY_LIMITED_INFORMATION`. Measured on ~540 processes, per detailed
scan: `ReadProcessMemory` 1128 → **0**, desired access `0x0410` → `0x1000`, with
byte-identical command lines on every process both readers recovered. There is no
PEB fallback to reinstate it — a hooked `ntdll` answering
`STATUS_INVALID_INFO_CLASS` for one target would have flipped a process-wide,
one-way switch back to `PROCESS_VM_READ` on exactly the machines this exists for.
What this change did remove is the `Memory` flag's second handle and its
`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves.
What remains to declare to administrators is unchanged in kind: one
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every
process on the box, at the shared snapshot's cadence. Moving to
`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION`
only) would genuinely retire the remote read, but it is an addon patch nobody has
written; treat it as unclaimed work, not as shipped.
Because the property is the *absence* of an import, it is checkable on the
artifact rather than the source: `inspectWindowsProcessTreeAddon()` answers
`clean` / `unpatched` / `missing`, and the rebuild, `ensure-native-runtime.mjs`,
the relay build and `loadWindowsProcessTree()` all key on it. That check is load-
bearing because the published tarball ships a *loadable* prebuilt built from
unpatched source, so "it required cleanly" is not evidence.
What to declare to administrators is now one
`PROCESS_QUERY_LIMITED_INFORMATION` handle per process on a detailed snapshot and
no remote memory access at all; an identity snapshot opens nothing. What this
does not narrow is _which_ processes are asked — a detailed scan still queries
every pid, including `lsass.exe`. Restricting the command-line pass to Orca's own
subtree needs job-object membership as its source of truth (a ppid-derived
allowlist would miss the detached, reparented descendants of #9045 and #10475),
and remains unclaimed work.
### Encoded, policy-bypassing PowerShell
@@ -232,7 +257,8 @@ obfuscated-command-line detector is tuned on.
### The spawn tree itself
`Orca.exe` → `orca-terminal-daemon.exe` → a shell → an agent CLI is what a
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
terminal multiplexer for coding agents *is*. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
@@ -363,7 +389,7 @@ The checklist. On Windows, do not reach for:
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | An installer or updater that does not need the rename. Where the rename is load-bearing, document it as such |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:

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