mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge reviewed application prerequisites from main
This commit is contained in:
+13
-1
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
+518
-96
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)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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).`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
Reference in New Issue
Block a user