mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into fix-ssh-relay-credential-wedge
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.
|
||||
|
||||
@@ -77,14 +77,6 @@ runs:
|
||||
;;
|
||||
esac
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare dependency install
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -175,6 +167,22 @@ runs:
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.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') }}
|
||||
|
||||
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
|
||||
- name: Use external node-gyp
|
||||
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
env:
|
||||
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
|
||||
NATIVE_CACHE_HIT: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }}
|
||||
run: |
|
||||
# A cache hit can contain unusable addons; probe before skipping the rebuild toolchain.
|
||||
if [ "$NATIVE_RUNTIME" = node ] && [ "$NATIVE_CACHE_HIT" = true ] &&
|
||||
node config/scripts/ensure-native-runtime.mjs --check-only; then
|
||||
exit 0
|
||||
fi
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare native runtime
|
||||
if: inputs.native-runtime != 'none'
|
||||
shell: bash
|
||||
|
||||
@@ -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
|
||||
"$@"
|
||||
@@ -127,9 +127,12 @@ jobs:
|
||||
esac
|
||||
# Bare: a work-tree repo refuses to fetch over its own checked-out
|
||||
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
|
||||
# blobs — so this stays cheap next to the build it fronts.
|
||||
# blobs — so this stays cheap next to the build it fronts. reftable
|
||||
# because this repo has branches that differ only in casing, and the
|
||||
# files backend cannot store both on a case-insensitive runner disk —
|
||||
# it fails the entire fetch, not just the one ref.
|
||||
scratch="$RUNNER_TEMP/vet-requested-ref"
|
||||
git init -q --bare "$scratch"
|
||||
git init -q --bare --ref-format=reftable "$scratch"
|
||||
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
# Branch first to keep actions/checkout's old tie-break: bare
|
||||
# rev-parse would prefer the tag when a branch shares its name.
|
||||
@@ -157,6 +160,9 @@ jobs:
|
||||
|
||||
- name: Checkout the requested ref
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
# Full-history checkout must also preserve case-twin branch and tag names.
|
||||
GIT_DEFAULT_REF_FORMAT: reftable
|
||||
with:
|
||||
# Why an input at all rather than just github.ref: the whole point is to
|
||||
# build code that has not landed, and the workflow definition itself
|
||||
|
||||
@@ -25,9 +25,10 @@ defaults:
|
||||
working-directory: cloud
|
||||
|
||||
jobs:
|
||||
# Public-repository hosted runners preserve Blacksmith allowance for macOS.
|
||||
security:
|
||||
name: Secret scan
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -53,7 +54,7 @@ jobs:
|
||||
# Compiles the workspace. No Postgres service: nothing here reaches a
|
||||
# database, and the service container costs ~13s of startup.
|
||||
build:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -73,7 +74,7 @@ jobs:
|
||||
# package it needs through the relay pretest hook, so it does not depend on
|
||||
# `pnpm build` having run.
|
||||
test:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -107,7 +108,7 @@ jobs:
|
||||
# Fork pull requests reach this job, so it never configures a backend, never plans, and never
|
||||
# holds a credential. Only the relay root ships here; foundation and apps stay private.
|
||||
terraform:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -149,9 +149,12 @@ jobs:
|
||||
fi
|
||||
# Reachability is the trust test: GitHub serves PR-only commits by SHA,
|
||||
# so resolving the object is not proof a branch or tag of this repo
|
||||
# reaches it. Bare + tree:0 keeps this to the commit graph.
|
||||
# reaches it. Bare + tree:0 keeps this to the commit graph; reftable
|
||||
# because branches that differ only in casing cannot both be stored by
|
||||
# the files backend on a case-insensitive runner disk, which fails the
|
||||
# entire fetch rather than the one ref.
|
||||
scratch="$RUNNER_TEMP/vet-requested-ref"
|
||||
git init -q --bare "$scratch"
|
||||
git init -q --bare --ref-format=reftable "$scratch"
|
||||
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
if ! git -C "$scratch" rev-parse --verify --quiet "$REQUESTED_SHA^{commit}" >/dev/null; then
|
||||
echo "::error::Commit $REQUESTED_SHA is not in stablyai/orca."
|
||||
|
||||
@@ -27,6 +27,10 @@ on:
|
||||
description: Ref to check out (defaults to the workflow ref)
|
||||
required: false
|
||||
type: string
|
||||
test_files:
|
||||
description: JSON array of specs to run; empty runs the full suite
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
|
||||
# America/Phoenix for the default-branch E2E run.
|
||||
@@ -146,7 +150,7 @@ jobs:
|
||||
# Native cache misses need the compiler, Electron needs Xvfb, and paired
|
||||
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -167,7 +171,7 @@ jobs:
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
# launches but never creates a BrowserWindow.
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
|
||||
|
||||
# Why: Playwright retains traces/screenshots only on failure. Uploading
|
||||
# them as an artifact makes post-mortem debugging on CI possible without
|
||||
@@ -201,7 +205,7 @@ jobs:
|
||||
# unbounded inventory fallback; the paired fixture exercises that real boundary.
|
||||
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
|
||||
# lane now receives those specs from pr.yml's SSH source mapping.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -241,7 +245,7 @@ jobs:
|
||||
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
|
||||
E2E_PROJECT_ARGS+=(--project=electron-headful)
|
||||
fi
|
||||
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
|
||||
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \
|
||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
|
||||
|
||||
- name: Upload Playwright traces
|
||||
@@ -278,7 +282,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Install native build and headless UI tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
@@ -293,7 +297,7 @@ jobs:
|
||||
# Why: this is the release-path proof that the deployed Linux relay keeps
|
||||
# its PTY and explorer live across a real watcher SIGSEGV.
|
||||
- name: Run Docker SSH watcher isolation E2E
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
|
||||
|
||||
# Why: Playwright empties test-results/ when it starts, so each step here used to
|
||||
# destroy the previous step's traces. Only the last lane's failure was ever
|
||||
@@ -310,7 +314,7 @@ jobs:
|
||||
# readiness across live SSH, headed paired, and headless serve topologies.
|
||||
- name: Run Docker SSH terminal parking + startup readiness E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
|
||||
|
||||
- name: Keep terminal-parking traces
|
||||
if: always()
|
||||
@@ -326,7 +330,7 @@ jobs:
|
||||
# legible as an SSH-named failure.
|
||||
- name: Run remaining Docker SSH E2E
|
||||
if: always()
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
|
||||
|
||||
- name: Keep remaining-ssh-docker traces
|
||||
if: always()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -26,7 +26,7 @@ name: Hourly macOS Dev Build
|
||||
# HOURLY_RELEASE_APP_ID the App's numeric id
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
|
||||
#
|
||||
# Installation tokens live one hour, which is why this mints twice. Install and
|
||||
# Installation tokens live one hour, so the build job mints twice. Install and
|
||||
# build need no token at all, and notarization can hold the publish step for tens
|
||||
# of minutes; minting again once the build is done starts the clock at the first
|
||||
# call that actually uses it rather than burning a third of it on `pnpm install`.
|
||||
@@ -60,33 +60,15 @@ env:
|
||||
HOURLY_RETAIN_COUNT: 72
|
||||
|
||||
jobs:
|
||||
build-hourly-mac:
|
||||
# Avoid occupying the limited Mac pool when main has not moved.
|
||||
preflight:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.hourly.outputs.version }}
|
||||
should_build: ${{ steps.freshness.outputs.should_build }}
|
||||
head_sha: ${{ steps.freshness.outputs.head_sha }}
|
||||
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
# the job is killed mid-retry and no cleanup step runs at all. A typical run
|
||||
# is far shorter — this is the notary queue's tail, not its median.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
# Why: this job only reads stablyai/orca and never pushes; every write
|
||||
# goes to the hourly repo through a minted App token passed by env.
|
||||
# Not persisting the checkout credential shrinks the blast radius if a
|
||||
# build step is compromised (zizmor: artipacked).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Mint hourly repo token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v2
|
||||
@@ -95,18 +77,19 @@ jobs:
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-hourly
|
||||
permission-contents: read
|
||||
|
||||
# Why: main is often idle overnight. Rebuilding an unchanged commit burns a
|
||||
# runner hour and adds a redundant tag to the retention window.
|
||||
- name: Check whether main moved since the last hourly
|
||||
id: freshness
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
head_sha="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)"
|
||||
[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Could not resolve main"; exit 1; }
|
||||
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
|
||||
if [[ "$FORCED" == "true" ]]; then
|
||||
echo "should_build=true" >>"$GITHUB_OUTPUT"
|
||||
@@ -133,21 +116,55 @@ jobs:
|
||||
echo "main moved to $head_sha (last hourly built $last_sha); building."
|
||||
fi
|
||||
|
||||
build-hourly-mac:
|
||||
needs: preflight
|
||||
if: needs.preflight.outputs.should_build == 'true'
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.hourly.outputs.version }}
|
||||
head_sha: ${{ needs.preflight.outputs.head_sha }}
|
||||
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
# the job is killed mid-retry and no cleanup step runs at all. A typical run
|
||||
# is far shorter — this is the notary queue's tail, not its median.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.head_sha }}
|
||||
fetch-depth: 0
|
||||
# Why: this job only reads stablyai/orca and never pushes; every write
|
||||
# goes to the hourly repo through a minted App token passed by env.
|
||||
# Not persisting the checkout credential shrinks the blast radius if a
|
||||
# build step is compromised (zizmor: artipacked).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Mint hourly repo token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: stablyai
|
||||
repositories: orca-hourly
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
@@ -158,7 +175,6 @@ jobs:
|
||||
electron-builder-mac-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
@@ -169,7 +185,6 @@ jobs:
|
||||
# Why: signing is what makes an hourly installable over an existing Orca, so
|
||||
# a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
@@ -180,7 +195,6 @@ jobs:
|
||||
|
||||
- name: Compute hourly version
|
||||
id: hourly
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
@@ -211,7 +225,7 @@ jobs:
|
||||
node config/scripts/hourly-build-version.mjs \
|
||||
>"$RUNNER_TEMP/hourly-identity.txt"
|
||||
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
|
||||
# Why check rather than trust: the checkout above pins `ref: main`, but a
|
||||
# Why check rather than trust: the checkout above pins the resolved main commit, but a
|
||||
# workflow_dispatch runs this file from whatever branch was dispatched. A
|
||||
# branch that edits this step while main still has the old script yields
|
||||
# an empty name and an untitled release — silent, and only visible once
|
||||
@@ -223,7 +237,6 @@ jobs:
|
||||
cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build app
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
@@ -239,7 +252,6 @@ jobs:
|
||||
# part the full budget.
|
||||
- name: Re-mint hourly repo token for publish
|
||||
id: app_token_publish
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
|
||||
@@ -249,13 +261,12 @@ jobs:
|
||||
|
||||
- name: Create hourly release
|
||||
id: release
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
TAG: v${{ steps.hourly.outputs.version }}
|
||||
NAME: ${{ steps.hourly.outputs.name }}
|
||||
SHA: ${{ steps.freshness.outputs.head_sha }}
|
||||
SHA: ${{ needs.preflight.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Kept at 12 even though the title shows 7: the freshness check above
|
||||
@@ -291,7 +302,6 @@ jobs:
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
# Why 45 like the release pipeline: an attempt is pack + notarize +
|
||||
@@ -322,7 +332,6 @@ jobs:
|
||||
# release missing that manifest is a tag the picker offers and the download
|
||||
# 404s on, so fail loudly instead of leaving a broken entry.
|
||||
- name: Verify update manifest published
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
@@ -352,7 +361,6 @@ jobs:
|
||||
# means the picker can never offer a release whose assets are incomplete.
|
||||
- name: Publish the verified release
|
||||
id: publish_live
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
|
||||
|
||||
@@ -15,8 +15,13 @@ on:
|
||||
# Why: this job holds the only checks that load the Fastfile, so edits to
|
||||
# it or to the release workflow it guards must re-run them.
|
||||
- '.github/workflows/mobile.yml'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/workflows/mobile-ios-release.yml'
|
||||
|
||||
concurrency:
|
||||
group: mobile-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -35,10 +40,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
|
||||
# proves the pinned fastlane the release workflow depends on still
|
||||
@@ -50,23 +52,6 @@ jobs:
|
||||
bundler-cache: true
|
||||
working-directory: mobile
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
# Why: the mobile typecheck imports shared types from ../src/shared, and
|
||||
# some of those files import runtime deps (tweetnacl, ws) resolved from
|
||||
# the repo-root node_modules. Without a root install, tsc fails with
|
||||
# "Cannot find module 'tweetnacl'/'ws'". Mobile is a separate pnpm project
|
||||
# (not in the root workspace), so this is a distinct install.
|
||||
# --ignore-scripts skips the root postinstall (Electron native-module
|
||||
# rebuild) which is irrelevant to a type-only check and would only add
|
||||
# time and failure surface on this ubuntu mobile runner.
|
||||
- name: Install root dependencies
|
||||
working-directory: .
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Performance contracts
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '15 9 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/performance-contracts.yml'
|
||||
- 'config/vitest.performance.config.ts'
|
||||
- 'config/oxlint-performance-audit.json'
|
||||
- 'config/oxlint-plugins/*performance.mjs'
|
||||
- 'config/oxlint-plugins/quadratic-buffer-concat.mjs'
|
||||
- 'config/scripts/*-plugin.test.mjs'
|
||||
# Keep in sync with the contract list in config/vitest.performance.config.ts;
|
||||
# without these a rename lands green and only breaks the next nightly.
|
||||
- 'src/main/sqlite/sync-database.test.ts'
|
||||
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
|
||||
- 'src/relay/fs-path-metadata-symlink-concurrency.test.ts'
|
||||
- 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts'
|
||||
- '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'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: performance-contracts-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
contracts:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Run operation-count and retention contracts
|
||||
run: pnpm test:perf:contracts --reporter=default --reporter=json --outputFile=performance-contracts.json
|
||||
# Source-only scan: identical on every OS, so run it once.
|
||||
- name: Audit production performance patterns
|
||||
if: always() && matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: pnpm --silent audit:perf > performance-audit.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: performance-contracts-${{ matrix.os }}
|
||||
path: performance-contracts.json
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always() && matrix.os == 'ubuntu-latest'
|
||||
with:
|
||||
name: performance-audit
|
||||
path: performance-audit.json
|
||||
if-no-files-found: error
|
||||
+58
-62
@@ -41,6 +41,10 @@ jobs:
|
||||
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
|
||||
package: ${{ steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.filter.outputs.package_windows }}
|
||||
e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }}
|
||||
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 }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -66,6 +70,38 @@ jobs:
|
||||
printf '%s\n' "$CHANGED"
|
||||
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Reuse the path-detector checkout instead of queuing another runner.
|
||||
- name: Filter changed E2E specs
|
||||
id: e2e_filter
|
||||
if: github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
# Source routes are executable contracts so a test can prove exact
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
|
||||
# spec name surviving in a route's list. Same routes, so the two cannot drift.
|
||||
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
|
||||
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "SSH source changed: $SSH_SOURCE_CHANGED"
|
||||
# Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must
|
||||
# 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"
|
||||
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
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No specs requiring the reusable E2E workflow"
|
||||
fi
|
||||
|
||||
static_analysis:
|
||||
name: static analysis
|
||||
needs: [code_paths]
|
||||
@@ -712,7 +748,11 @@ jobs:
|
||||
- name: Package unpacked app
|
||||
env:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish never
|
||||
# PR artifacts are only inspected locally; gzip avoids release-size xz compression.
|
||||
run: >-
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs
|
||||
--linux AppImage deb rpm --x64 --publish never
|
||||
--config.deb.compression=gz --config.rpm.compression=gzip
|
||||
|
||||
- name: Verify root-package marker payloads
|
||||
run: |
|
||||
@@ -792,10 +832,13 @@ jobs:
|
||||
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
|
||||
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
|
||||
|
||||
# vitest runs here directly rather than through `pnpm test`, so the addon
|
||||
# assertions only hold once install-node-dependencies has rebuilt natives.
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/rebuild-native-deps.test.mjs
|
||||
config/scripts/rebuild-native-deps-windows-process-tree.test.mjs
|
||||
src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts
|
||||
src/main/browser/browser-route-tcp-egress.electron.test.ts
|
||||
src/main/browser/browser-route-webrtc-egress.electron.test.ts
|
||||
@@ -804,9 +847,13 @@ jobs:
|
||||
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
|
||||
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
|
||||
src/shared/child-process/windows-command-line.win32.test.ts
|
||||
src/shared/child-process/windows-cmd-shim-resolution.test.ts
|
||||
src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts
|
||||
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
|
||||
src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts
|
||||
src/main/windows/windows-pty-job.win32.test.ts
|
||||
src/main/windows/windows-host-job.win32.test.ts
|
||||
src/main/windows/windows-process-tree-command-line-patch.test.ts
|
||||
src/main/windows-live-tree-kill.win32.test.ts
|
||||
src/main/wsl/wsl-runner.test.ts
|
||||
src/main/wsl/wsl-guest-environment.test.ts
|
||||
@@ -815,14 +862,18 @@ jobs:
|
||||
src/main/wsl/wsl-w1-w3-contract.test.ts
|
||||
src/shared/source-scan/source-tree-scan.test.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/computer/desktop-script-runtime-host.win32.test.ts
|
||||
src/main/cursor/hook-service.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/main/startup/windows-install-dir-acl-repair.win32.test.ts
|
||||
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
|
||||
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
src/shared/secure-path-windows-acl.win32.test.ts
|
||||
src/main/runtime/unreadable-secret-store-preservation.win32.test.ts
|
||||
src/main/ipc/pty-codex-account-attribution.test.ts
|
||||
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
|
||||
src/relay/windows-port-scan.win32.test.ts
|
||||
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
@@ -861,65 +912,10 @@ jobs:
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
# Why: PR E2E is advisory and only validates changed specs; scheduled and
|
||||
# release runs retain full-suite coverage.
|
||||
e2e-paths:
|
||||
name: detect changed e2e specs
|
||||
needs: [code_paths]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'
|
||||
# Why: detector only needs to read the checkout; do not inherit repo defaults.
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
test_files: ${{ steps.filter.outputs.test_files }}
|
||||
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
|
||||
native_ime_source_changed: ${{ steps.filter.outputs.native_ime_source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why blob:none: full history is needed for the merge-base diff, but historical
|
||||
# file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
|
||||
# few this job actually reads on demand.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- name: Filter changed E2E specs
|
||||
id: filter
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
# Source routes are executable contracts so a test can prove exact
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
|
||||
# spec name surviving in a route's list. Same routes, so the two cannot drift.
|
||||
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
|
||||
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
|
||||
echo "SSH source changed: $SSH_SOURCE_CHANGED"
|
||||
# Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must
|
||||
# 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"
|
||||
echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed E2E specs: $TEST_FILES_JSON"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changed E2E specs"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
name: e2e
|
||||
needs: e2e-paths
|
||||
if: needs.e2e-paths.outputs.should_run == 'true'
|
||||
needs: code_paths
|
||||
if: needs.code_paths.outputs.e2e_should_run == 'true'
|
||||
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -928,8 +924,8 @@ jobs:
|
||||
# The synthetic pull-request merge ref can disappear while this reusable
|
||||
# workflow is queued. The head SHA is immutable and works for every PR.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
test_files: ${{ needs.e2e-paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
|
||||
test_files: ${{ needs.code_paths.outputs.test_files }}
|
||||
ssh_source_changed: ${{ needs.code_paths.outputs.ssh_source_changed }}
|
||||
|
||||
# Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability
|
||||
# is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It
|
||||
@@ -939,8 +935,8 @@ jobs:
|
||||
# require `success || skipped` outside the strict loop — see the note on `e2e`.
|
||||
terminal_ime_native:
|
||||
name: real IME
|
||||
needs: e2e-paths
|
||||
if: needs.e2e-paths.outputs.native_ime_source_changed == 'true'
|
||||
needs: code_paths
|
||||
if: needs.code_paths.outputs.native_ime_source_changed == 'true'
|
||||
# Why: the reusable workflow only checks out, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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
|
||||
@@ -858,16 +852,17 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: Linux terminal golden E2E uses the same native install path as
|
||||
# release CI, which needs pnpm to bypass its non-executable gyp_main.py.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
@@ -1074,16 +1069,17 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: keep the non-blocking evidence lane on the same Linux native
|
||||
# install path as the blocking golden and release build jobs.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
@@ -1425,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
|
||||
@@ -1451,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
|
||||
@@ -1498,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'
|
||||
@@ -1642,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
|
||||
@@ -1651,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'
|
||||
@@ -1665,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
|
||||
@@ -1686,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).
|
||||
@@ -1716,6 +1795,7 @@ jobs:
|
||||
with:
|
||||
name: orca-windows-unsigned-${{ needs.cut.outputs.tag }}
|
||||
path: dist/orca-windows-setup.exe
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
# Why: SignPath Foundation production certificates require manual review,
|
||||
@@ -1876,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'
|
||||
|
||||
@@ -1956,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)) {
|
||||
@@ -1988,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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Release ref validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/adhoc-mac-build.yml'
|
||||
- '.github/workflows/dev-channel-win-build.yml'
|
||||
- '.github/workflows/release-ref-validation.yml'
|
||||
- 'config/scripts/workflow-ref-reachability.test.mjs'
|
||||
- 'config/scripts/workflow-ref-mirror-case-safety.test.mjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-ref-validation-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-15, windows-2022]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Verify case-twin refs and release trust boundary
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/workflow-ref-reachability.test.mjs
|
||||
config/scripts/workflow-ref-mirror-case-safety.test.mjs
|
||||
config/scripts/dev-channel-windows-workflow-contract.test.mjs
|
||||
@@ -22,6 +22,10 @@ on:
|
||||
- main
|
||||
paths: *skill-roundtrip-paths
|
||||
|
||||
concurrency:
|
||||
group: skill-roundtrip-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
roundtrip:
|
||||
strategy:
|
||||
@@ -41,7 +45,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Historical skill snapshots need tags, but only their blobs are read.
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@@ -38,23 +38,9 @@ jobs:
|
||||
xfwm4
|
||||
xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
native-runtime: electron
|
||||
|
||||
- name: Build Electron app for E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
@@ -67,16 +67,17 @@ jobs:
|
||||
- name: Install native build tools and xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: this scheduled/manual workflow uses the same native install path as
|
||||
# PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy
|
||||
|
||||
@@ -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 }
|
||||
@@ -215,6 +259,7 @@ jobs:
|
||||
with:
|
||||
name: orca-windows-installer-unsigned-${{ github.run_id }}
|
||||
path: dist/orca-windows-setup.exe
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit Windows installer signing request
|
||||
@@ -288,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,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()
|
||||
|
||||
@@ -110,6 +110,8 @@ docs/**
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-cmd-shim-resolution.md
|
||||
!docs/reference/windows-daemon-host-relocation.md
|
||||
!docs/reference/windows-edr-posture.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
!docs/reference/wsl-runner-verification.md
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "sort-comparator-performance",
|
||||
"specifier": "./config/oxlint-plugins/sort-comparator-performance.mjs"
|
||||
},
|
||||
{
|
||||
"name": "mobile-pairing",
|
||||
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
|
||||
@@ -23,6 +27,7 @@
|
||||
"correctness": "error"
|
||||
},
|
||||
"rules": {
|
||||
"sort-comparator-performance/no-repeated-collator": "warn",
|
||||
"app-store-performance/require-selector": "error",
|
||||
"app-store-performance/no-identity-selector": "error",
|
||||
"app-store-performance/no-fresh-selector-result": "error",
|
||||
|
||||
@@ -4,6 +4,12 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
|
||||
## Electron UI Validation
|
||||
|
||||
Always run tests and agent-launched apps in the background with `ORCA_BACKGROUND_LAUNCH=1`.
|
||||
Never steal monitor focus or reveal test windows: no `show()`, `showInactive()`, `bringToFront()`,
|
||||
`app.focus()`, or OS activation. Use CDP screenshots of hidden renderers. Keep native-focus and
|
||||
visible-window tests paused on the user's desktop; run them on an isolated display or CI.
|
||||
Rebuild modified launch-policy code before running an app; stale build wrappers are not safe.
|
||||
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
@@ -47,8 +53,9 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
|
||||
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one.
|
||||
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
|
||||
- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md).
|
||||
- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them.
|
||||
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
|
||||
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type WebSocket from 'ws'
|
||||
import type { RelayAssignmentStore } from './assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import type { CredentialReservation, RelayCredentialStore } from './credential-store.js'
|
||||
import {
|
||||
CONTROL_LEASE_JITTER_MS,
|
||||
CONTROL_LEASE_MS,
|
||||
HostSessionRegistry
|
||||
} from './host-session-registry.js'
|
||||
import type { RelayRuntimeObserver } from './relay-observability.js'
|
||||
import type { RelayTokenClaims } from './relay-token-verifier.js'
|
||||
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
|
||||
|
||||
// Incident 2026-09-04 ~01:05Z: the phone's dial bound ran out while the cell was
|
||||
// still inside acceptClient's serialized Postgres phase (cell-inventory lock
|
||||
// contention). The cell then finished the work for a socket nobody held, holding
|
||||
// an activity lease for the 10s attach deadline before its timer unwound it, and
|
||||
// logged `host_data_reservation_already_bound`.
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
readyState = this.OPEN
|
||||
readonly send = vi.fn()
|
||||
readonly close = vi.fn((code?: number, reason?: string) => {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close', code, Buffer.from(reason ?? ''))
|
||||
})
|
||||
readonly terminate = vi.fn(() => {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close')
|
||||
})
|
||||
}
|
||||
|
||||
const config = {
|
||||
port: 8080,
|
||||
publicUrl: 'https://relay-c3.example.com',
|
||||
cellUrl: 'https://relay-c3.example.com',
|
||||
authIssuer: 'https://auth.example.com',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.com/jwks',
|
||||
assignmentSigningKey: new Uint8Array(32),
|
||||
role: 'cell',
|
||||
cellId: 'production-gce-c3',
|
||||
cells: [{ id: 'production-gce-c3', url: 'https://relay-c3.example.com', capacityRequests: 4_000 }],
|
||||
adminAudience: 'https://relay-c3.example.com/v1/admin/drain',
|
||||
deployServiceAccount: 'deploy@example.com',
|
||||
runtimeServiceAccount: 'runtime@example.com',
|
||||
adminJwksUrl: 'https://auth.example.com/admin-jwks',
|
||||
databasePoolMax: 10,
|
||||
publicAssignmentsEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './test-data'
|
||||
} satisfies RelayConfig
|
||||
|
||||
const identity = {
|
||||
sub: 'user-1',
|
||||
prof: 'profile-1',
|
||||
relayHostId: 'abcdefghijklmnop',
|
||||
purpose: 'host-control',
|
||||
exp: 4_102_444_800
|
||||
} satisfies RelayTokenClaims
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((next) => (resolve = next))
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const reservation: CredentialReservation = {
|
||||
userId: identity.sub,
|
||||
relayHostId: identity.relayHostId,
|
||||
credentialKind: 'resume',
|
||||
relayDeviceId: 'device-1',
|
||||
tokenHash: 'hash',
|
||||
reservationId: 'reservation-1',
|
||||
leaseExpiresAt: Date.now() + 60_000,
|
||||
acceptedCredentialVersion: 2,
|
||||
acceptedAs: 'current'
|
||||
}
|
||||
|
||||
function harness(options: { random?: () => number; now?: () => number } = {}) {
|
||||
const acquireActivity = vi.fn().mockResolvedValue(undefined)
|
||||
const releaseActivity = vi.fn().mockResolvedValue(true)
|
||||
const assignments = {
|
||||
activateControl: vi.fn().mockResolvedValue('control:production-gce-c3:1'),
|
||||
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
|
||||
resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }),
|
||||
acquireActivity,
|
||||
renewControlActivity: vi.fn().mockResolvedValue(undefined),
|
||||
releaseActivity
|
||||
} as unknown as RelayAssignmentStore
|
||||
const store = {
|
||||
resolveResume: vi.fn().mockResolvedValue({ userId: identity.sub }),
|
||||
reserveCredential: vi.fn().mockResolvedValue(reservation),
|
||||
failReservation: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
const observer = {
|
||||
recordAuth: vi.fn(),
|
||||
recordForwardedBytes: vi.fn(),
|
||||
recordHttp: vi.fn(),
|
||||
recordReconnect: vi.fn(),
|
||||
recordSql: vi.fn(),
|
||||
recordClientAcceptAbandoned: vi.fn()
|
||||
} satisfies RelayRuntimeObserver
|
||||
const registry = new HostSessionRegistry(
|
||||
config,
|
||||
vi.fn(),
|
||||
store as unknown as RelayCredentialStore,
|
||||
assignments,
|
||||
new ProcessQueuedByteBudget(),
|
||||
observer,
|
||||
options.now,
|
||||
options.random
|
||||
)
|
||||
const activate = (
|
||||
registry as unknown as {
|
||||
activate: (
|
||||
socket: WebSocket,
|
||||
identity: RelayTokenClaims,
|
||||
existing: null,
|
||||
generation: number,
|
||||
rebind: boolean,
|
||||
assignmentEpoch: number,
|
||||
appVersion: string
|
||||
) => Promise<void>
|
||||
}
|
||||
).activate.bind(registry)
|
||||
return { registry, store, assignments, acquireActivity, releaseActivity, observer, activate }
|
||||
}
|
||||
|
||||
async function activeHost(h: ReturnType<typeof harness>): Promise<FakeSocket> {
|
||||
const control = new FakeSocket()
|
||||
await h.activate(control as unknown as WebSocket, identity, null, 1, false, 1, '1.4.197')
|
||||
return control
|
||||
}
|
||||
|
||||
describe('client accept abandoned mid-DB-phase', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stops after a slow activity acquire when the phone already hung up', async () => {
|
||||
const h = harness()
|
||||
const control = await activeHost(h)
|
||||
const slowAcquire = deferred<void>()
|
||||
h.acquireActivity.mockReturnValueOnce(slowAcquire.promise)
|
||||
const capacity = { bind: vi.fn(), release: vi.fn() }
|
||||
const client = new FakeSocket()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
const accepting = h.registry.acceptClient(
|
||||
client as unknown as WebSocket,
|
||||
identity.relayHostId,
|
||||
'credential',
|
||||
capacity
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.acquireActivity).toHaveBeenCalledOnce()
|
||||
// The phone's 12s bound fires while the cell still waits on Postgres.
|
||||
client.close(1000, 'client bound')
|
||||
capacity.release()
|
||||
slowAcquire.resolve()
|
||||
await accepting
|
||||
|
||||
// No conn-open reached the desktop; nothing pending; the lease it just took is
|
||||
// released instead of leaking to expiry cleanup; bind never throws.
|
||||
expect(control.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open'))
|
||||
expect(capacity.bind).not.toHaveBeenCalled()
|
||||
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
||||
expect(session?.pendingConns.size).toBe(0)
|
||||
expect(h.store.failReservation).toHaveBeenCalledWith(reservation)
|
||||
expect(h.releaseActivity).toHaveBeenCalledWith(
|
||||
{ userId: identity.sub, relayHostId: identity.relayHostId },
|
||||
expect.stringMatching(/^confirmation:/)
|
||||
)
|
||||
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
||||
'activity',
|
||||
expect.any(Number)
|
||||
)
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('orca_relay_client_accept_abandoned')
|
||||
)
|
||||
expect(line).toBeDefined()
|
||||
expect(JSON.parse(line!)).toMatchObject({ stage: 'activity' })
|
||||
expect(line).not.toContain(identity.relayHostId)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
h.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops after a slow credential reservation without acquiring an activity lease', async () => {
|
||||
const h = harness()
|
||||
await activeHost(h)
|
||||
const slowReserve = deferred<CredentialReservation>()
|
||||
h.store.reserveCredential.mockReturnValueOnce(slowReserve.promise)
|
||||
const client = new FakeSocket()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
const accepting = h.registry.acceptClient(
|
||||
client as unknown as WebSocket,
|
||||
identity.relayHostId,
|
||||
'credential'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
client.close(1000, 'client bound')
|
||||
slowReserve.resolve(reservation)
|
||||
await accepting
|
||||
|
||||
expect(h.acquireActivity).not.toHaveBeenCalled()
|
||||
expect(h.store.failReservation).toHaveBeenCalledWith(reservation)
|
||||
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
||||
'credential',
|
||||
expect.any(Number)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
h.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops after a slow resume lookup before starting the invite and assignment lookups', async () => {
|
||||
const h = harness()
|
||||
await activeHost(h)
|
||||
const store = h.store as typeof h.store & { resolveInviteForMove: ReturnType<typeof vi.fn> }
|
||||
store.resolveInviteForMove = vi.fn().mockResolvedValue(null)
|
||||
const slowResume = deferred<null>()
|
||||
h.store.resolveResume.mockReturnValueOnce(slowResume.promise)
|
||||
const resolveAssignment = (h.assignments as unknown as { resolve: ReturnType<typeof vi.fn> })
|
||||
.resolve
|
||||
resolveAssignment.mockClear()
|
||||
const client = new FakeSocket()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
const accepting = h.registry.acceptClient(
|
||||
client as unknown as WebSocket,
|
||||
identity.relayHostId,
|
||||
'credential'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
client.close(1000, 'client bound')
|
||||
slowResume.resolve(null)
|
||||
await accepting
|
||||
|
||||
expect(store.resolveInviteForMove).not.toHaveBeenCalled()
|
||||
expect(resolveAssignment).not.toHaveBeenCalled()
|
||||
expect(h.store.reserveCredential).not.toHaveBeenCalled()
|
||||
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
||||
'assignment',
|
||||
expect.any(Number)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
h.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops after a slow same-cell assignment resolve, before reserving a credential', async () => {
|
||||
const h = harness()
|
||||
await activeHost(h)
|
||||
const resolveAssignment = (h.assignments as unknown as { resolve: ReturnType<typeof vi.fn> })
|
||||
.resolve
|
||||
const slowResolve = deferred<{ cellId: string }>()
|
||||
resolveAssignment.mockReturnValueOnce(slowResolve.promise)
|
||||
const client = new FakeSocket()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
const accepting = h.registry.acceptClient(
|
||||
client as unknown as WebSocket,
|
||||
identity.relayHostId,
|
||||
'credential'
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
client.close(1000, 'client bound')
|
||||
// A correct, same-cell assignment: only the closed socket stops the accept.
|
||||
slowResolve.resolve({ cellId: config.cellId })
|
||||
await accepting
|
||||
|
||||
// Proves the accept reached the third guard, not the first.
|
||||
expect(resolveAssignment).toHaveBeenCalled()
|
||||
expect(h.store.reserveCredential).not.toHaveBeenCalled()
|
||||
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
||||
'assignment',
|
||||
expect.any(Number)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
h.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('still opens the connection when the phone is holding on', async () => {
|
||||
const h = harness()
|
||||
const control = await activeHost(h)
|
||||
const capacity = { bind: vi.fn(), release: vi.fn() }
|
||||
const client = new FakeSocket()
|
||||
await h.registry.acceptClient(
|
||||
client as unknown as WebSocket,
|
||||
identity.relayHostId,
|
||||
'credential',
|
||||
capacity
|
||||
)
|
||||
expect(control.send).toHaveBeenCalledWith(expect.stringContaining('"type":"conn-open"'))
|
||||
expect(capacity.bind).toHaveBeenCalledOnce()
|
||||
expect(h.observer.recordClientAcceptAbandoned).not.toHaveBeenCalled()
|
||||
expect(client.close).not.toHaveBeenCalled()
|
||||
h.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control lease jitter', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('grants a lease uniformly around its mean so cohorts drift apart at the same mean rate', async () => {
|
||||
const now = 1_700_000_000_000
|
||||
const helloAck = (socket: FakeSocket) =>
|
||||
JSON.parse(
|
||||
String(socket.send.mock.calls.find((call) => String(call[0]).includes('host-hello-ack'))![0])
|
||||
) as { leaseExpiresAt: number }
|
||||
|
||||
const shortest = harness({ now: () => now, random: () => 0 })
|
||||
const shortestAck = helloAck(await activeHost(shortest))
|
||||
const centered = harness({ now: () => now, random: () => 0.5 })
|
||||
const centeredAck = helloAck(await activeHost(centered))
|
||||
const longestRoll = 0.999999
|
||||
const longest = harness({ now: () => now, random: () => longestRoll })
|
||||
const longestAck = helloAck(await activeHost(longest))
|
||||
|
||||
// Pinned, not bounded: a jitter clamped to one side still satisfies an upper
|
||||
// bound, so only the exact top of the band proves it is symmetric.
|
||||
const longestOffset = Math.floor((longestRoll * 2 - 1) * CONTROL_LEASE_JITTER_MS)
|
||||
expect(shortestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS)
|
||||
expect(centeredAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS)
|
||||
expect(longestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS + longestOffset)
|
||||
shortest.registry.drain(0)
|
||||
centered.registry.drain(0)
|
||||
longest.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
})
|
||||
|
||||
it('rebinds re-roll the jitter instead of pinning the cohort phase', async () => {
|
||||
const now = 1_700_000_000_000
|
||||
let roll = 0
|
||||
const h = harness({ now: () => now, random: () => roll })
|
||||
const first = await activeHost(h)
|
||||
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
||||
const firstLease = session.leaseExpiresAt
|
||||
roll = 0.75
|
||||
const rebind = new FakeSocket()
|
||||
await (
|
||||
h.registry as unknown as {
|
||||
activate: (...args: unknown[]) => Promise<void>
|
||||
}
|
||||
).activate(rebind as unknown as WebSocket, identity, session, 1, true, 1, '1.4.197')
|
||||
expect(session.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS + CONTROL_LEASE_JITTER_MS / 2)
|
||||
expect(session.leaseExpiresAt).not.toBe(firstLease)
|
||||
expect(first.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.PEER_DROPPED, 'control rebound')
|
||||
h.registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
|
||||
import { relayHostLogDigest } from './relay-host-log-digest.js'
|
||||
import type { RelayTokenClaims } from './relay-token-verifier.js'
|
||||
import type { RelayRuntimeObserver } from './relay-observability.js'
|
||||
import type { RelayClientAcceptStage, RelayRuntimeObserver } from './relay-observability.js'
|
||||
import type { PendingHostDataReservation } from './relay-connection-ledger.js'
|
||||
import { closeRelayWebSocket } from './relay-websocket-close.js'
|
||||
import { ProcessQueuedByteBudget, wireSplice } from './splice-forwarder.js'
|
||||
@@ -129,6 +129,16 @@ function send(socket: WebSocket, type: string, message: object): void {
|
||||
// stalled predecessor only accumulates doomed sockets.
|
||||
const ACTIVATION_QUEUE_WAIT_MS = 30_000
|
||||
|
||||
// Why: this lease bounds how long a host lingers on a cell after a missed drain,
|
||||
// and rebinding it is the only passive rebalancing we have, so it has to stay
|
||||
// finite. 6h keeps both properties while cutting control-activation traffic on
|
||||
// the contended cell-inventory lock ~6x; the relay JWT (5 min, refreshed by the
|
||||
// desktop) and the 75s silence watchdog are enforced separately, so a longer
|
||||
// grant authorizes nothing extra. Symmetric jitter walks same-minute reconnect
|
||||
// cohorts apart across cycles without changing the mean rebind rate.
|
||||
export const CONTROL_LEASE_MS = 6 * 60 * 60 * 1000
|
||||
export const CONTROL_LEASE_JITTER_MS = 30 * 60 * 1000
|
||||
|
||||
export class HostSessionRegistry {
|
||||
private readonly sessions = new Map<string, HostSession>()
|
||||
private readonly activationQueues = new Map<string, Promise<void>>()
|
||||
@@ -145,9 +155,16 @@ export class HostSessionRegistry {
|
||||
private readonly assignments: RelayAssignmentStore,
|
||||
private readonly queuedByteBudget: ProcessQueuedByteBudget,
|
||||
private readonly observer: RelayRuntimeObserver,
|
||||
private readonly now: () => number = Date.now
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly random: () => number = Math.random
|
||||
) {}
|
||||
|
||||
// Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter).
|
||||
private controlLeaseExpiresAt(): number {
|
||||
const offset = Math.floor((this.random() * 2 - 1) * CONTROL_LEASE_JITTER_MS)
|
||||
return this.now() + CONTROL_LEASE_MS + offset
|
||||
}
|
||||
|
||||
async acceptClient(
|
||||
socket: WebSocket,
|
||||
hostId: string,
|
||||
@@ -159,10 +176,31 @@ export class HostSessionRegistry {
|
||||
this.rejectClient(socket, RELAY_CLOSE_CODE.DRAINING)
|
||||
return
|
||||
}
|
||||
// Why: the accept runs several serialized Postgres calls behind the contended
|
||||
// cell-inventory lock, and phones bound their dial. Finishing the work for a
|
||||
// phone that already hung up took an activity lease held for the 10s attach
|
||||
// deadline, then failed at bind with host_data_reservation_already_bound.
|
||||
const acceptStartedAt = this.now()
|
||||
const abandonedByClient = (stage: RelayClientAcceptStage, cleanup?: () => void): boolean => {
|
||||
if (socket.readyState === socket.OPEN) return false
|
||||
capacityReservation?.release()
|
||||
cleanup?.()
|
||||
const elapsedMs = this.now() - acceptStartedAt
|
||||
this.observer.recordClientAcceptAbandoned?.(stage, elapsedMs)
|
||||
console.warn(
|
||||
JSON.stringify({ event: 'orca_relay_client_accept_abandoned', stage, elapsedMs })
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (this.config.role === 'cell') {
|
||||
const outerIdentity =
|
||||
(await this.store.resolveResume(hostId, credential)) ??
|
||||
(await this.store.resolveInviteForMove(hostId, credential))
|
||||
// Each lookup is its own pooled round trip; stop between them once the phone
|
||||
// has left instead of running the rest of the chain for nobody.
|
||||
let outerIdentity = await this.store.resolveResume(hostId, credential)
|
||||
if (abandonedByClient('assignment')) return
|
||||
if (!outerIdentity) {
|
||||
outerIdentity = await this.store.resolveInviteForMove(hostId, credential)
|
||||
if (abandonedByClient('assignment')) return
|
||||
}
|
||||
const assignment = outerIdentity
|
||||
? await this.assignments.resolve({ userId: outerIdentity.userId, relayHostId: hostId })
|
||||
: null
|
||||
@@ -172,6 +210,7 @@ export class HostSessionRegistry {
|
||||
this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL)
|
||||
return
|
||||
}
|
||||
if (abandonedByClient('assignment')) return
|
||||
}
|
||||
const reservation = await this.store.reserveCredential(hostId, credential)
|
||||
if (!reservation) {
|
||||
@@ -181,6 +220,7 @@ export class HostSessionRegistry {
|
||||
return
|
||||
}
|
||||
this.observer.recordAuth(true)
|
||||
if (abandonedByClient('credential', () => this.failReservationBestEffort(reservation))) return
|
||||
const sessionKey = this.key(reservation.userId, hostId)
|
||||
const session = this.sessions.get(sessionKey)
|
||||
if (
|
||||
@@ -227,6 +267,14 @@ export class HostSessionRegistry {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (
|
||||
abandonedByClient('activity', () => {
|
||||
this.failReservationBestEffort(reservation)
|
||||
if (credentialActivityId) this.releaseActivityBestEffort(identity, credentialActivityId)
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
const attachTimer = setTimeout(() => {
|
||||
session.pendingConns.delete(connId)
|
||||
capacityReservation?.release()
|
||||
@@ -740,7 +788,7 @@ export class HostSessionRegistry {
|
||||
existing.socket = socket
|
||||
existing.state = existing.regionalDrainAttemptId ? 'drain-only' : 'active'
|
||||
existing.appVersion = appVersion
|
||||
existing.leaseExpiresAt = this.now() + 55 * 60 * 1000
|
||||
existing.leaseExpiresAt = this.controlLeaseExpiresAt()
|
||||
existing.lastPongAt = this.now()
|
||||
existing.activityRenewalDueAt =
|
||||
this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs
|
||||
@@ -791,7 +839,7 @@ export class HostSessionRegistry {
|
||||
appVersion,
|
||||
state: 'active',
|
||||
socket,
|
||||
leaseExpiresAt: this.now() + 55 * 60 * 1000,
|
||||
leaseExpiresAt: this.controlLeaseExpiresAt(),
|
||||
orphanTimer: null,
|
||||
heartbeatTimer: null,
|
||||
lastPongAt: this.now(),
|
||||
|
||||
@@ -195,16 +195,23 @@ describe('relay observability', () => {
|
||||
observability.recordControlClose(4402)
|
||||
observability.recordSpliceClose('host-oversize-frame')
|
||||
observability.recordSpliceClose('queue-limit')
|
||||
observability.recordClientAcceptAbandoned('activity', 14_250.4)
|
||||
observability.recordClientAcceptAbandoned('activity', 2_000)
|
||||
observability.recordClientAcceptAbandoned('credential', 3_000)
|
||||
observability.flush(counts)
|
||||
observability.flush(counts)
|
||||
|
||||
expect(entries[0]).toMatchObject({
|
||||
controlClosesByCodeDelta: { 1006: 2, 4402: 1 },
|
||||
spliceClosesByTriggerDelta: { 'host-oversize-frame': 1, 'queue-limit': 1 }
|
||||
spliceClosesByTriggerDelta: { 'host-oversize-frame': 1, 'queue-limit': 1 },
|
||||
clientAcceptsAbandonedByStageDelta: { activity: 2, credential: 1 },
|
||||
clientAcceptAbandonedMsMax: 14_250.4
|
||||
})
|
||||
expect(entries[1]).toMatchObject({
|
||||
controlClosesByCodeDelta: {},
|
||||
spliceClosesByTriggerDelta: {}
|
||||
spliceClosesByTriggerDelta: {},
|
||||
clientAcceptsAbandonedByStageDelta: {},
|
||||
clientAcceptAbandonedMsMax: 0
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -64,8 +64,12 @@ export interface RelayRuntimeObserver {
|
||||
}): void
|
||||
recordControlClose?(code: number): void
|
||||
recordSpliceClose?(trigger: string): void
|
||||
recordClientAcceptAbandoned?(stage: RelayClientAcceptStage, elapsedMs: number): void
|
||||
}
|
||||
|
||||
// Which serialized accept step the phone had already hung up behind.
|
||||
export type RelayClientAcceptStage = 'assignment' | 'credential' | 'activity'
|
||||
|
||||
type RelayMetricDeltas = {
|
||||
forwardedBytes: number
|
||||
authSuccesses: number
|
||||
@@ -87,6 +91,8 @@ type RelayMetricDeltas = {
|
||||
unavailableRegions: Record<string, number>
|
||||
controlClosesByCode: Record<string, number>
|
||||
spliceClosesByTrigger: Record<string, number>
|
||||
clientAcceptsAbandonedByStage: Record<string, number>
|
||||
clientAcceptAbandonedMsMax: number
|
||||
controlRenewalLatenciesMs: number[]
|
||||
controlRenewalsByOutcome: Record<string, number>
|
||||
controlActivityRecoveries: number
|
||||
@@ -116,6 +122,8 @@ const emptyDeltas = (): RelayMetricDeltas => ({
|
||||
unavailableRegions: {},
|
||||
controlClosesByCode: {},
|
||||
spliceClosesByTrigger: {},
|
||||
clientAcceptsAbandonedByStage: {},
|
||||
clientAcceptAbandonedMsMax: 0,
|
||||
controlRenewalLatenciesMs: [],
|
||||
controlRenewalsByOutcome: {},
|
||||
controlActivityRecoveries: 0,
|
||||
@@ -228,6 +236,14 @@ export class RelayObservability implements RelayRuntimeObserver {
|
||||
(this.deltas.spliceClosesByTrigger[trigger] ?? 0) + 1
|
||||
}
|
||||
|
||||
recordClientAcceptAbandoned(stage: RelayClientAcceptStage, elapsedMs: number): void {
|
||||
increment(this.deltas.clientAcceptsAbandonedByStage, stage)
|
||||
this.deltas.clientAcceptAbandonedMsMax = Math.max(
|
||||
this.deltas.clientAcceptAbandonedMsMax,
|
||||
elapsedMs
|
||||
)
|
||||
}
|
||||
|
||||
start(readCounts: () => RelayProcessCounts, intervalMs = 30_000): void {
|
||||
if (this.timer) return
|
||||
this.eventLoop.enable()
|
||||
@@ -289,6 +305,8 @@ export class RelayObservability implements RelayRuntimeObserver {
|
||||
unavailableRegionsDelta: deltas.unavailableRegions,
|
||||
controlClosesByCodeDelta: deltas.controlClosesByCode,
|
||||
spliceClosesByTriggerDelta: deltas.spliceClosesByTrigger,
|
||||
clientAcceptsAbandonedByStageDelta: deltas.clientAcceptsAbandonedByStage,
|
||||
clientAcceptAbandonedMsMax: Number(deltas.clientAcceptAbandonedMsMax.toFixed(3)),
|
||||
sqlQueriesDelta: deltas.sqlQueries,
|
||||
sqlFailuresDelta: deltas.sqlFailures,
|
||||
sqlLatencyMsMax: Number(deltas.sqlLatencyMsMax.toFixed(3)),
|
||||
|
||||
@@ -88,6 +88,7 @@ export function createRelayServer(
|
||||
database: RelayDatabase,
|
||||
options: {
|
||||
now?: () => number
|
||||
random?: () => number
|
||||
connectionLedgerLimits?: { hardCap: number; controlReserve: number }
|
||||
cellIncarnation?: string
|
||||
} = {}
|
||||
@@ -123,7 +124,8 @@ export function createRelayServer(
|
||||
assignments,
|
||||
queuedBytes,
|
||||
observability,
|
||||
options.now
|
||||
options.now,
|
||||
options.random
|
||||
)
|
||||
const app = createRelayApp(config, {
|
||||
store,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Relay improvement: implementation checklist, lanes, and disruption
|
||||
|
||||
Companion to [`relay-improvement-roadmap-2026-09.md`](./relay-improvement-roadmap-2026-09.md) (item numbers
|
||||
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
|
||||
|
||||
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**
|
||||
- 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.
|
||||
- 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**
|
||||
- Refresh rotation grace window (orca-cloud #478). Startup adds one nullable column (brief exclusive lock on `refresh_tokens`).
|
||||
- Pruning job code (orca-cloud #476) is in the image; the job itself is Terraform-disabled until 1.2.
|
||||
|
||||
**Merged, ships with the next desktop release**
|
||||
- Never replay a refresh token after a timeout; ±10 % jitter on relay lease renewal (#18719).
|
||||
- Renderer learns when a cloud session is revoked (#18694).
|
||||
|
||||
**Merged, not applied**
|
||||
- Incident dashboard (#18717) blocked behind the runtime-metric label drift (5.x first item).
|
||||
- Monitor probe fix (#18723) is live in the workflow; the same-cap roll gate has not yet produced a green dry-run since.
|
||||
|
||||
**Awaiting owner go (production mutations)**
|
||||
1. Roll 1 cell image roll (1.1): dry-run gate, then c8 canary, then batches.
|
||||
2. Auth deploy carrying #478 (3.1): quiet minute for the column add.
|
||||
3. orca-cloud #477 private IP (2.1): merge arms an instance restart and a one-way door. Recommendation: hold.
|
||||
4. Runtime-metric `region` label drift (5.x): intentional replacement of 21 metrics, or drop the label.
|
||||
5. Enable pruning (1.2): first budget 20k rows; needs a Terraform apply.
|
||||
6. Paging channel for auth alerts (5.2): needs the destination from you.
|
||||
|
||||
**Open code follow-ups (no gate, nobody assigned)**
|
||||
- Monitor summary Markdown does not render `tolerated: true` continuity events (added by #18798); the state artifact has them, the checkpoint table does not.
|
||||
- Relay container boot races the `cloud-sql-proxy` sidecar: c13's fresh container exited twice (`applyPostgresSchema` connection timeout, 2 s each) before the proxy was listening. Make schema apply wait for the proxy or order the containers.
|
||||
- `cloud-deploy-relay-production-capacity-job.yml` (~line 416) has the same wave-0 single-shot preflight carve-out that #18778 removes from the same-cap job; its single-evidence path never retries freshness-only failures.
|
||||
- `cloud/package.json` `test` names every dev-script test file explicitly; an unregistered `*.test.mjs` is silently never run in CI (found by #18769). Needs a glob or a ratchet that fails on an unlisted test file.
|
||||
- Same-cap job's verify step uses bare `curl --fail-with-body` against the just-rolled cell; one 503 at the LB warm-up edge failed c8 canary #2 (run 33935407461) after the transition verifier had already passed. Needs a bounded retry, same rule as #18723/#18740.
|
||||
- `verify-mutation` in `cloud-deploy-relay-production.yml`, the multi-target workflow, and the capacity workflow still binds to an exact commit; same exposure #18754 fixed for the same-cap and rehome paths.
|
||||
- `incident-live-preflight-cli.ts` reports only `source/code` (`active-probe/threshold_max`) with no signal name or observed value, so a failed mutation preflight (c27 recovery #3, run 33986948522) cannot be attributed to an endpoint without an out-of-band probe. Print the signal and observed/threshold pair. Related: the 2 000 ms `endpointLatencyMs` bar is shared by US and Asia cells while Asia /health round trips from a US runner sit at 0.7–1.3 s idle; consider a per-region bar or the p50 of the gate window instead of one shot. Gates #44 and #45 (2026-09-05) both froze on `cell.production-gce-c27.latency_ms` at 2.6–2.7 s with c28 showing the identical tail under operator probes; the bar is now blocking Asia rolls. **Fix: stablyai/orca #18877** (per-region `cellEndpointLatencyMs`, us-central1 2 000 / asia-east2 4 000, plus signal/observed/threshold in preflight messages). Residual: `probeEndpointHealth` in `resource-inventory.ts` still uses the flat 2 000 bar to decide whether to retry after the 10 s readiness-cache wait, so a healthy Asia cell over 2 s costs one extra probe per sample (latency, not verdict); thread the region bar into the retry decision.
|
||||
- The root oxlint config ignores `cloud/**`, so `check:code-quality:changed` never inspects relay-ops or the cloud dev scripts; typecheck + vitest is the only gate there.
|
||||
- Monitor bars that froze on non-health today: `directorInstancesMin: 5` with `latest-sum` (one-minute instance recycle), `endpointLatencyMs: 2000` on a US-runner probe to asia-east2, `cloudDataMaxAgeMs: 180000` vs Cloud Monitoring publish lag up to 255 s. Recalibrate with a week of data.
|
||||
- `parsed()` in `resource-inventory.ts` still returns null on a 200 with a malformed MIG body; a second path to `runtime_power_unknown`.
|
||||
- Deploy script strips `ORCA_CLOUD_REFRESH_TOKEN_TTL_DAYS` on every release (3.1 first item).
|
||||
- `assignOnce` placement lock still global (4.1 remainder).
|
||||
- Region preference (4.2), retries-bar recalibration after a week of Roll 2 data (4.4), pruner `stopReason` alert (1.5).
|
||||
- Full apps-root apply for 4 unrelated drifts (1.4), from a host with the 1Password account.
|
||||
|
||||
## Uplift ranking (reliability gained per unit of effort)
|
||||
|
||||
| Rank | Item | Why it ranks here |
|
||||
|---|---|---|
|
||||
| 1 | 1.1 cell image roll | Removes the only crash mode we have seen in production. 22 of 23 cells still have it. One afternoon. |
|
||||
| 2 | 3.1 refresh rotation grace window | Turns the entire "slow auth → mass sign-out" class into a slowdown. One day. |
|
||||
| 3 | 4.1 inventory lock contention | The floor under every 503 and slow phone accept, every day, not just incidents. One week. |
|
||||
| — | 2.2 relay/auth database split | **Deferred 2026-09-04** to ~2026-11-01. Biggest structural fix, but the concrete cause is fixed and alerts now page; see roadmap 2.2 for re-open triggers. |
|
||||
| 4 | 1.2 + 1.3 pruning and reclaim | Defuses the 63 M-row time bomb. Low effort, mostly waiting. |
|
||||
| 5 | 5.1 + 5.2 crash alert, page a human | Cheapest detection uplift; today's incident ran 4 h unpaged. |
|
||||
| 6 | 2.1 private IP | Durable version of a fix that already landed (dynamic NAT ports). Do it on the existing instance. |
|
||||
| 7 | 4.3 + 3.2 desktop hardening | Small, ride the normal desktop release. |
|
||||
| 8 | 4.2, 4.4, 5.4, 1.4, 1.5 | Housekeeping and quality-of-life. |
|
||||
|
||||
## The shared bottleneck: cell rolls
|
||||
|
||||
Every change to what runs on a cell (image, proxy flag, env, relay code) needs a same-cap roll: drain →
|
||||
recreate → verify, one wave at a time, gated by the 15-minute monitor, about an afternoon. Each wave forces
|
||||
the desktops on that cell to re-dial (c7 canary: 807 controls re-dialed in ~10 s) and phones on those
|
||||
desktops reconnect on their normal retry. Users see a few seconds of "reconnecting" per wave.
|
||||
|
||||
So batch. Two rolls, not five:
|
||||
|
||||
- **Roll 1 (now):** current image only (1.1). Do not wait for anything else.
|
||||
- **Roll 2 (week 2–3):** proxy `--private-ip` (2.1) + relay pool `statement_timeout` (2.3) + lock-contention
|
||||
fix (4.1), all in one image/template. Prerequisite: 2.1's peering and private IP exist first.
|
||||
|
||||
## Lanes (independent; different people can own them)
|
||||
|
||||
```
|
||||
Lane A data plane 1.1 roll ──────────────────► Roll 2 (2.1 flag + 2.3 + 4.1) ──► 4.4 recalibrate
|
||||
Lane B auth/DB 1.2 enable pruning ──(10 d)──► 1.3 reclaim 3.1 grace window (any time)
|
||||
Lane C network 2.1 peering + private IP ─────┐ (feeds Roll 2) (2.2 DB split deferred)
|
||||
Lane D desktop 3.2 no same-token retry, 4.3 lease jitter (any release; wire-compatible)
|
||||
Lane E observability 1.5, 5.1, 5.2, 5.4 (Terraform only, any time)
|
||||
Lane F director 4.2 region preference (Cloud Run deploy, any time)
|
||||
Misc 1.4 full apps-root apply (any time; see its check)
|
||||
```
|
||||
|
||||
Hard dependencies: Roll 2 waits on 2.1's network work; 1.3 waits on 1.2 finishing. Everything else is
|
||||
independent. (2.2 deferred; if revived, do it after 2.1 so the new instance is private from day one.)
|
||||
|
||||
## Disruption summary
|
||||
|
||||
| Item | User-visible? | What they see | Mitigation |
|
||||
|---|---|---|---|
|
||||
| 1.1 / Roll 2 | **Yes, transient** | Per wave, desktops on that cell reconnect within seconds; phones follow on retry. | Waves gated by the monitor; run in the US night. Already rehearsed on c7. |
|
||||
| 1.2 pruning | No | Background deletes, 5k rows per batch. | Small first budget; watch `stopReason` and Cloud SQL write throughput. Stop the scheduler if checkpoint alerts fire. |
|
||||
| 1.3 reclaim | **Depends on tool** | `VACUUM FULL` takes an exclusive lock on `refresh_tokens`: sign-in and refresh block for its duration (minutes to tens of minutes on 16 GB). `pg_repack` holds only brief locks. | Use `pg_repack`. If VACUUM FULL, announce a maintenance window. |
|
||||
| 1.4 full apps apply | Should be none, **verify** | Terraform will create a new auth revision (env added). Traffic is pinned to `00031-tox` by name, so the new revision should receive 0 %. | Confirm in the plan that no `traffic` change appears. If it does, stop: the Terraform image variable is not the serving image. |
|
||||
| 1.5, 5.x alerts | No | | |
|
||||
| 2.1 private IP | **Yes, certain** | Google: "Configuring an existing Cloud SQL instance to use private IP causes the instance to restart, resulting in downtime." No in-place path, HA does not avoid it. Expect 1–2 min DB unavailability: sign-in fails, relay renewals retry. **One-way door**: private IP cannot be disabled and the VPC link cannot be removed once set. The proxy flag change rides Roll 2. | Off-peak; only after Roll 1 (old image dies on a 2 min DB blip). Owner decision required before the foundation apply. |
|
||||
| 2.2 DB split (deferred) | **Yes, scheduled** | Relay unavailable for the cutover (drain all cells → copy relay tables → flip `DATABASE_URL` → restart). Minutes if rehearsed. Desktops and phones reconnect automatically after. | Rehearse on staging; do it in the US night; announce. |
|
||||
| 2.3 statement timeout | No beyond Roll 2 | | |
|
||||
| 3.1 grace window | No | Auth deploys are no-traffic candidate → smoke → promote. | Security trade-off: a stolen token replayed inside the window is served once instead of revoking. 60 s is the usual choice. |
|
||||
| 3.2, 4.3 desktop | No | Normal app update. | |
|
||||
| 4.1 lock fix | No beyond Roll 2 | | Verify against real Postgres on 55440 with concurrent probes before shipping. |
|
||||
| 4.2 region preference | **Minor, Asia users** | Phones that start being placed in Asia reconnect once to a nearer cell. | Roll out behind the existing region-preference flag. |
|
||||
| 4.4 | No | | |
|
||||
|
||||
## Checklists
|
||||
|
||||
### 1.1 Cell image roll (Roll 1)
|
||||
- [x] Confirm fleet is quiet: 15-min monitor dry-run passes. #19 green 23:07:53Z (run 33927238469). Canary then failed the evidence provenance check because main moved during the gate; re-gating with a same-commit chain.
|
||||
- [x] Confirm director is on 519f4914 and c7 on 85bf6799 (confirmed 2026-09-04 via instance-template census; 20 serving cells still on `5aedbca5`) (`verify` mode of the same-cap workflow).
|
||||
- [x] Dispatch `cloud-deploy-relay-production-same-cap` waves per the plan in the findings doc; one wave, verify, next. Done 2026-09-05 01:14Z–22:27Z: c8 canary, US batches c9–c10, c13–c16, c19–c26 at protocol 1, then Asia c27 (recovered via `mode=rollback` re-entry after gate freezes on the flat latency bar, fixed by #18877), c28, c29 as single-cell canaries at protocol 0.
|
||||
- [x] After each wave: the transition verifier passed at migration-only and again at general on every cell (assignments carried, heartbeat fresh, hard cap 3 000); no `container die` fleet-wide across the whole roll. The 4408/1006 burst per wave was not measured separately; the verifier's assignment count before and after each restart is the recovery evidence recorded.
|
||||
- [x] Record image census in the findings doc. 2026-09-05 22:27Z: all 19 general cells on `519f4914` except c7 on `85bf6799`; existing-only c1–c6, c11, c12 and migration-only c17, c18 untouched on their older images by design. Selector at gen 148.
|
||||
|
||||
### 1.2 Enable pruning
|
||||
- [x] `auth_token_pruner_image` = digest of `orca-cloud-auth-00031-tox` (`343a0915…`; it contains the entrypoint). orca-cloud #479 merged.
|
||||
- [x] `auth_token_pruner_enabled = true`, `auth_token_pruner_max_rows_per_run = 20000` for the first day (orca-cloud #479).
|
||||
- [x] Targeted plan asserted 9 create / 0 change / 0 destroy. Applied 2026-09-05 02:06Z.
|
||||
- [x] Trigger one run by hand; read the summary event. 02:18Z: `time-budget`, 73 batches, 365k scanned, 1 040 deleted (1 021 revoked, 19 expired), no errors. Scan-bound.
|
||||
- [ ] Raise the budget to the default 200k after a clean day; watch Cloud SQL write MB/s and the checkpoint alert.
|
||||
- [ ] 1.5: log metric + policy on `stopReason != complete`.
|
||||
|
||||
### 1.3 Reclaim
|
||||
- [ ] Wait for steady-state runs deleting ~0 rows.
|
||||
- [ ] `pg_repack -t refresh_tokens` off-peak (needs the extension; check `pg_available_extensions`). Not `VACUUM FULL` without a window.
|
||||
- [ ] Confirm table + index size and `disk/utilization` dropped.
|
||||
|
||||
### 1.4 Full apps-root apply
|
||||
- [ ] Run from CI or a host with the 1Password account (local plan fails on the Cloudflare data source).
|
||||
- [ ] Plan shows exactly the four known drifts and **no traffic change** on `google_cloud_run_v2_service.auth`.
|
||||
- [ ] Apply; confirm `status.traffic` still pins `00031-tox` at 100 %.
|
||||
|
||||
### 2.1 Private IP (PRs open: orca-cloud #477 foundation, stablyai/orca #18720 relay flag)
|
||||
- [ ] **Owner decision**: the foundation apply restarts the instance and is irreversible on Google's side. Merging #477 arms the next foundation apply; hold the merge until the window is chosen.
|
||||
- [ ] Director is out of scope: it uses the Cloud Run built-in connector (managed Google path, not the relay VPC NAT), so it consumed none of the exhausted ports; moving it needs Direct VPC egress + a separate DSN secret. Own PR if ever wanted.
|
||||
- [ ] Step 7 (`ipv4_enabled=false`) is blocked until humans have IAP/bastion access and the director is moved; it breaks both today.
|
||||
- [ ] Allocate a `/24` private services range on the relay VPC; `google_service_networking_connection`.
|
||||
- [ ] Add `ip_configuration.private_network` to `google_sql_database_instance.auth` (foundation root). Plan must show update, not replace.
|
||||
- [ ] Apply off-peak; expect a possible restart. Watch auth 5xx alert and relay `sqlFailures`.
|
||||
- [ ] Cell template: proxy args add `--private-ip` (code merged #18720; flag not set). Director: Direct VPC egress or connector, then the same flag. Both ride Roll 2.
|
||||
- [ ] After Roll 2: NAT `port_usage` for relay gateways drops to ~0; then consider `ipv4_enabled = false` (removes the public IP; breaks the local `cloud-sql-proxy --token` workflow unless it also goes private).
|
||||
|
||||
### 2.2 Database split (deferred to ~2026-11-01; checklist kept for when it is revived)
|
||||
- [ ] New `google_sql_database_instance.relay` (private IP from day one, its own size and flags). Staging first.
|
||||
- [ ] Relay schema applies cleanly to an empty instance (it does at startup).
|
||||
- [ ] Rehearsal on staging: drain → `pg_dump` relay tables → restore → flip `relay_database_url` secret → restart director + cells → phones/desktops reconnect. Time it.
|
||||
- [ ] 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)
|
||||
- [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.
|
||||
|
||||
### 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).
|
||||
- [x] `rotateRefreshToken`: if `rotated_at` within 60 s and not revoked, return the existing successor (idempotent), no revoke, no audit.
|
||||
- [x] Outside the window or a third presentation: unchanged (revoke + audit).
|
||||
- [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)
|
||||
- [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).
|
||||
|
||||
### 4.1 Lock contention (partial: stablyai/orca #18722 merged; ships Roll 2)
|
||||
- [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.
|
||||
|
||||
### 4.2 Region preference
|
||||
- [ ] Director: honor requested region when the preferred region has headroom, else sticky. Behind the existing flag.
|
||||
- [ ] Measure with `orca_relay_runtime_metrics` region counters before/after.
|
||||
|
||||
### 5.x Observability
|
||||
- [x] **Relay-root runtime-metric drift**: resolved by dropping the `region` label to match live state (stablyai/orca #18734). Applied 2026-09-04 23:11Z: 8 never-applied `control_*` renewal metrics + the incident dashboard created, 0 destroyed, 21 live metrics untouched.
|
||||
- [x] 5.1 `container die` log metric per cell (`relay_cell_process_exit`, applied 2026-09-04 via #18717), > 3 / 15 min, relay channel.
|
||||
- [ ] 5.2 Add a paging channel (**needs owner input**: destination) to `auth_alert_notification_channels` for refresh rejections + latency.
|
||||
- [x] 5.4 One dashboard (applied 2026-09-04 23:11Z): `orca_relay_cloud_sql_wal_checkpoint`, NAT drops, `orca_auth_refresh_401`, summed `controls`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Relay improvement roadmap (written 2026-09-04, after the auth/relay outage)
|
||||
|
||||
Owner-facing list of what is left to make the relay more robust, in priority order. Evidence and history
|
||||
for every item is in [`relay-reconnect-2026-09-findings.md`](./relay-reconnect-2026-09-findings.md)
|
||||
(Findings 1–13). Everything already landed on 2026-09-04 is listed at the end so this file is complete on
|
||||
its own.
|
||||
|
||||
## 1. Finish what 2026-09-04 started (this week)
|
||||
|
||||
| # | Item | Why | How | Size |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | **Roll all 23 cells onto the current relay image** | Every cell still runs the image that exits the whole process on a Postgres connect timeout (Finding 6). The fixed image runs only on the director and c7. Any future DB stall repeats the 200-crashes-in-48h pattern. | `cloud-deploy-relay-production-same-cap` waves, gated by the 15-min monitor. Roll inputs and canary results are in the findings doc ("Roll inputs", "Canary blast radius"). | one afternoon |
|
||||
| 1.2 | **Enable the refresh_tokens pruning job** (orca-cloud #476, merged, off) | `refresh_tokens` is 63 M rows / 26 GB and grows forever; its size is what turned a slow disk into a sign-out storm (Finding 13). | Build an auth image from main (the 21:04Z deploy already contains the entrypoint: `orca-cloud-auth-00031-tox`, digest `343a0915…`), set `auth_token_pruner_enabled = true` and the image digest in `infra/terraform-apps/environments/production.tfvars`, apply targeted. First run with a small `auth_token_pruner_max_deleted_rows`. Watch the run summary's `stopReason`, not the exit code. ~48 M rows drain in ~10 days at 200k/hour. | 1 hour + 10 days of watching |
|
||||
| 1.3 | **Reclaim the disk after pruning** | Deletes leave dead tuples; the 16 GB table does not shrink on its own. | `pg_repack` (or `VACUUM FULL` in a maintenance window; it takes an exclusive lock) on `refresh_tokens` off-peak, after 1.2 finishes. | 1 evening |
|
||||
| 1.4 | **Full Terraform apply of the orca-cloud apps root** | The production plan carries four drifts from other merged work: `ORCA_CLOUD_REFRESH_TOKEN_TTL_DAYS` env on the auth service (#476), a skill-share log exclusion filter change, skill pressure threshold 16→8, an artifacts bucket lifecycle rule. Locally it also fails on the 1Password Cloudflare data source. | Run from CI or a machine with the 1Password account; review the four drifts as ordinary changes. | 30 min |
|
||||
| 1.5 | **Alert on the pruning job** | A run that only ever times out exits 0 and reads as green. | Log metric on the job's summary event where `stopReason != "complete"`, policy on the relay channel. | 1 hour |
|
||||
|
||||
## 2. Remove the shared fate between auth and relay (2.1 and 2.3 this quarter; 2.2 deferred)
|
||||
|
||||
| # | Item | Why | How | Size |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | **Private IP for Cloud SQL, `--private-ip` on the cell proxies** (do this on the existing shared instance; do not wait for 2.2) | Cells reach the database's public IP through Cloud NAT. Dynamic port allocation (landed) raised the ceiling from 64 to 4096 ports per VM, but the NAT is still in the path and its logs are still the only place port exhaustion shows up (Finding 11). | Add a private IP to `orca-cloud-auth-db` (foundation root, orca-cloud), peer the relay VPC, switch the proxy flag in the cell template, roll. | 1–2 days |
|
||||
| 2.2 | **Split the relay database from the auth database** — *DEFERRED 2026-09-04 (owner decision): revisit ~2026-11-01 once pruning is done and there is a month of alert history* | One Cloud SQL instance serves `orca_auth`, `orca_relay`, `orca_push`, `orca_skills`. The auth table's growth stalled the relay for a day (Findings 10, 13). Deferral rationale: the concrete cause is fixed (disk 250 GB, WAL 16 GB, index, pruning), 2.3 + 1.1 turn a future stall into retries, and the checkpoint/disk/headroom alerts now page. Re-open if the checkpoint-loop or connection-headroom alert fires, or a large new auth-side table is planned. | New instance for `orca_relay`; migrate with a short relay drain. Relay state is small so the cutover is minutes. | 1–2 weeks incl. rehearsal on staging |
|
||||
| 2.3 | **Statement timeouts on the relay pool** (the auth pool got one in #476) | A relay query stuck behind a checkpoint fsync should fail fast and let the bounded retry take over rather than hold a pool slot for seconds. | `statement_timeout` on the relay `pg.Pool` in `cloud/apps/relay`, tuned under the lease renewal deadline. | half a day |
|
||||
|
||||
## 3. Make the desktop refresh path forgiving (next 2 weeks)
|
||||
|
||||
| # | Item | Why | How | Size |
|
||||
|---|---|---|---|---|
|
||||
| 3.1 | **Refresh-token rotation grace window** | The server revokes the whole family the first time a just-rotated token is presented again. On 2026-09-04 that turned a 30 s server slowdown into 21,605 sign-outs. A short window (e.g. 60 s) where the immediately-previous token is still accepted, returning the same new token, is standard practice. | In `apps/auth/src/tokens/refresh-tokens.ts`: accept `rotated_at` within the window, return the successor instead of revoking. Keep true reuse (outside the window, or a third presentation) as revocation. | 1 day incl. tests |
|
||||
| 3.2 | **Do not retry `/refresh` with the same token on timeout** | Desktop's 30 s `CLOUD_REQUEST_TIMEOUT_MS` expiring is treated like a network error and retried with a token the server may already have rotated. | In `src/main/orca-profiles/profile-cloud-session-refresh.ts`: on timeout, re-read the stored session first, and prefer a longer single attempt for the refresh call specifically. | half a day |
|
||||
| 3.3 | **Un-revoke is impossible; make sign-out recovery obvious instead** | Server-side un-revoke does not help because the desktop deletes its local token on the 401. Landed: desktop notices immediately (#18694) and the phone says "desktop signed out" (#18698). | Nothing more unless we want a re-auth deep link from the phone to the desktop. | — |
|
||||
|
||||
## 4. Chronic relay issues already characterised
|
||||
|
||||
| # | Item | Why | How | Size |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | **Cell-inventory lock contention** (partial: PR #18722 narrowed the remaining non-placement sites; `assignOnce` placement lock is the follow-up) | `postgres_retries` is a global `FOR UPDATE` over the 23-row `relay_cells` table with a 1 s `lock_timeout`; it is the floor under every 503 and every slow phone accept (Findings 2, 5; memory `relay-cell-inventory-lock-contention`). | Per-cell row locks or an advisory lock keyed by cell; move capacity counters to delta writes. Verify against real Postgres on 55440. | 1 week |
|
||||
| 4.2 | **Region preference is mostly inert** | Phones request an Asia cell on ~19 % of attempts and get one ~6 % of the time; the sticky lane wins silently, so Asia users ride the US path more than intended (memory `relay-region-preference-mostly-inert`). | Let a region preference override stickiness when the preferred region has headroom; measure with `orca_relay_runtime_metrics` region counters. | 2–3 days |
|
||||
| 4.3 | **Desktop lease-rotation waves** | A cell recreate seeds a fleet-wide 1006/4408 reconnect burst ~54 min later, every ~54 min (Finding 3). | Jitter the desktop control lease renewal by ±10 % so the cohort spreads out. | half a day, desktop + wire-compatible |
|
||||
| 4.4 | **Raise `postgres_retries` gate calibration** | The 300 bar was recalibrated (PR #18580) but should track the post-lock-fix baseline once 4.1 lands. | Re-derive from a week of `orca_relay_postgres_transaction_retry` counts. | 1 hour |
|
||||
|
||||
## 5. Observability still missing
|
||||
|
||||
| # | Item | Why | How |
|
||||
|---|---|---|---|
|
||||
| 5.1 | **Cell crash-rate alert** | 201 process exits in 48 h with no page (Finding 6). | Log metric on `container die` for `resource.type="gce_instance"` relay cells, > 3 per 15 min per cell. In `cloud/infra/terraform/relay-observability.tf`. |
|
||||
| 5.2 | **Page a person for auth alerts** | Today's four auth policies (orca-cloud #475) route to the relay Slack channel only. A repeat of 2026-09-04 deserves a page. | Add a PagerDuty/phone notification channel to `auth_alert_notification_channels` for refresh rejections and latency. |
|
||||
| 5.3 | **Pruning job alert** | See 1.5. | |
|
||||
| 5.4 | **Dashboard that puts the four signals side by side** | Diagnosis took hours because checkpoint state, NAT drops, auth 401 rate, and fleet controls live in four consoles. | One Cloud Monitoring dashboard: `orca_relay_cloud_sql_wal_checkpoint`, NAT `dropped_sent_packets_count`, `orca_auth_refresh_401`, summed `controls`. |
|
||||
|
||||
## Landed on 2026-09-04 (for completeness)
|
||||
|
||||
- Auth service cap 2 → 20 (service-level manual scaling removed); Cloud SQL disk 49 → 250 GB PD-SSD;
|
||||
`max_wal_size` 16384; partial index `refresh_tokens_family_unrevoked` built concurrently by hand.
|
||||
- orca-cloud #474: the above in Terraform + deploy workflow; replayed dead token answers 401 without
|
||||
re-revoking or re-auditing. Deployed as `orca-cloud-auth-00031-tox` 21:04Z.
|
||||
- orca-cloud #475: auth alerts (refresh 401 > 100/5 min, 429 > 20/5 min, 5xx > 10/5 min, p99 > 10 s). Applied.
|
||||
- orca-cloud #476: batched `refresh_tokens` pruner (disabled), auth pool `statement_timeout` 10 s, schema
|
||||
DDL on an untimed connection.
|
||||
- stablyai/orca #18693: both relay NATs on dynamic port allocation 64..4096 (applied US 21:01Z, Asia 21:05Z);
|
||||
alerts for Cloud SQL WAL-checkpoint loop, disk > 70 %, NAT `OUT_OF_RESOURCES` drops. Applied.
|
||||
- stablyai/orca #18694: desktop learns of a revoked session immediately, panes re-fetch on mount, pairing
|
||||
notice says "Sign in again to use Orca Relay".
|
||||
- stablyai/orca #18698: phone shows "Desktop signed out — sign in to Orca on your desktop to reconnect" via
|
||||
the WebSocket close reason (only additive slot old phones tolerate).
|
||||
- Director on image 519f4914; c7 on 85bf6799; other 22 cells still on the old image (see 1.1).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
# Relay Roll 2 and close-out plan (2026-09-05)
|
||||
|
||||
Owner-approved scope 2026-09-05: finish the relay reliability work with one more cell image roll,
|
||||
deferring the Cloud SQL private-IP move (2.1, orca-cloud #477) to a separate owner decision. Roll 1
|
||||
is complete (see `relay-reconnect-2026-09-findings.md`, "Roll 1 complete"); every serving cell runs
|
||||
`519f4914` except c7 on `85bf6799`.
|
||||
|
||||
Estimate: about two working days of effort over one week of calendar time. The cell roll itself is
|
||||
6 to 7 hours of mostly unattended wall clock, run in the US night.
|
||||
|
||||
## Phase 0. Land the code (half a day, no production change)
|
||||
|
||||
### 0a. Split PR #18565
|
||||
|
||||
The branch mixes three relay/mobile/desktop fixes with the operator record. Split so the record
|
||||
lands regardless of how the code review goes.
|
||||
|
||||
- **Docs PR** (new branch off main): `relay-reconnect-2026-09-findings.md`,
|
||||
`relay-improvement-checklist-2026-09.md`, `relay-improvement-roadmap-2026-09.md`, this file.
|
||||
Docs only, merge on CI green.
|
||||
- **Code PR** (rebase #18565 onto main, resolve two conflicts):
|
||||
- `cloud/apps/relay/src/host-session-registry.ts`: conflict with #18698 (signed-out signal).
|
||||
Keep both; the accept-abandonment and lease changes are orthogonal to the signed-out path.
|
||||
- `src/main/runtime/relay/relay-origin-pool.ts`: **drop this branch's version**. #18719 already
|
||||
merged the desktop early-window jitter (1 to 6 min). Also drop
|
||||
`relay-session-broker.test.ts` additions that only exercise the dropped change.
|
||||
- Keep: relay accept abandonment (`orca_relay_client_accept_abandoned` event), relay-side lease
|
||||
jitter, mobile direct-probe fail-fast, and their tests.
|
||||
|
||||
### 0b. Lengthen the control lease (same code PR)
|
||||
|
||||
In `cloud/apps/relay/src/host-session-registry.ts`:
|
||||
|
||||
```
|
||||
CONTROL_LEASE_MS = 6 * 60 * 60 * 1000 // was 55 min
|
||||
CONTROL_LEASE_JITTER_MS = 30 * 60 * 1000 // was 5 min
|
||||
```
|
||||
|
||||
Why 6 h: the lease bounds how long a host stays on a cell after a missed drain and is the only
|
||||
passive rebalancing; 6 h keeps both and cuts control-activation traffic on the inventory lock by
|
||||
about 6x. Nothing else depends on it: the relay JWT (5 min) is refreshed by the desktop on its own
|
||||
schedule and liveness is the 75 s silence watchdog. Wire-safe: the relay sends `leaseExpiresAt` in
|
||||
the hello ack and old desktops schedule from that value.
|
||||
|
||||
Update the comment above the constants and the three assertions in
|
||||
`host-session-client-accept.test.ts` that pin the lease arithmetic. Check that nothing in
|
||||
`cloud/apps/relay-ops` or the monitor thresholds assumes a 55 min rotation period (grep
|
||||
`55`, `CONTROL_LEASE`, `rotation`).
|
||||
|
||||
### 0c. Review and merge
|
||||
|
||||
Review rounds per the standing process (Opus review, then Codex pass). Merge order: docs PR first
|
||||
(no dependency), then the code PR. Record the merge SHA of the code PR; that is the Roll 2 image
|
||||
source.
|
||||
|
||||
## Phase 1. Build and stage the image (half a day)
|
||||
|
||||
Roll 2 image = code PR merge SHA. It carries, relative to `519f4914`:
|
||||
|
||||
| Change | PR | Effect |
|
||||
|---|---|---|
|
||||
| Per-cell inventory locks, delta counters | #18722 | Removes the global `relay_cells FOR UPDATE` behind the phone accept hang |
|
||||
| Relay pool `statement_timeout` 5 s | #18722 | A relay query can no longer hang a cell |
|
||||
| Accept abandonment | #18565 | Cell stops finishing accepts for phones that already closed |
|
||||
| Control lease 6 h ± 30 min | #18565 | Fewer, spread-out rebinds |
|
||||
| `--private-ip` proxy flag support | #18720 | Code only; flag stays unset until 2.1 |
|
||||
|
||||
Steps, in order (from the findings doc's post-merge dispatch plan):
|
||||
|
||||
1. `gh workflow run cloud-publish-relay-production.yml --ref main -f mode=publish`. Resolve the
|
||||
digest by tag, not from the log:
|
||||
`gcloud artifacts docker images describe us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay:sha-<merge-sha> --format='value(image_summary.digest)'`.
|
||||
2. Staging: `cloud-deploy-relay-staging.yml` with the new digest; paired phone plus desktop smoke
|
||||
(connect, background, reconnect). Confirm `orca_relay_client_accept_abandoned` appears only when
|
||||
a client closes early, and that `sqlLatencyMsMax` no longer pins at the lock timeout.
|
||||
3. Director: `cloud-deploy-relay-production-director.yml -f image-digest=<new>
|
||||
-f regional-placement-mode=preserve -f prune-incompatible-revisions=false
|
||||
-f expected-rehome-generation=12 -f bootstrap-runtime-identity=false
|
||||
-f predecessor-image-digest=<serving digest>`. Blue/green; prior revision stays as rollback.
|
||||
Watch director `orca_relay_postgres_transaction_retry` per minute before and after. The director
|
||||
goes first so the per-cell locks are live before any cell restart burst.
|
||||
4. Same-cap `verify` mode against c7 with target=<new>, rollback=`519f4914`. Read-only.
|
||||
|
||||
Go/no-go for Phase 2: director serving the new image for at least 30 min, retries per minute at or
|
||||
below the pre-deploy baseline, no `container die`, no auth 5xx.
|
||||
|
||||
## Phase 2. Roll the cells (one US night, mostly unattended)
|
||||
|
||||
Same machinery as Roll 1: `cloud-monitor-relay-production.yml` dry-run gate, then
|
||||
`cloud-deploy-relay-production-same-cap.yml`. Cells roll one at a time by design (exact selector
|
||||
assertions, single Terraform state, and one cell's ~1.2k-host reconnect burst per restart). Do not
|
||||
add parallelism for this roll.
|
||||
|
||||
Inputs: target=<new digest>, rollback=`519f4914` (c7: rollback=`85bf6799`). Selector membership is
|
||||
unchanged from the end of Roll 1 (gen 148; existing-only c1–c6, c11, c12; migration-only c17, c18).
|
||||
|
||||
Order:
|
||||
|
||||
1. **c7 canary** (`canary-apply`, protocol 1). c7 is the rehearsal cell and the only one not on
|
||||
`519f4914`.
|
||||
2. **c8 canary**, then **batch c9, c10, c13, c14**.
|
||||
3. **c15 canary**, then **batch c16, c19, c20, c21**.
|
||||
4. **c22 canary**, then **batch c23, c24, c25, c26**.
|
||||
5. **Asia c27, c28, c29** as three single canaries at protocol 0 (`PROTO=0`). Batch mode cannot
|
||||
take Asia cells yet and needs at least two cells.
|
||||
|
||||
Each batch needs a same-commit canary authority; each wave needs a fresh 15 min gate. Use the
|
||||
chain script pattern from Roll 1 (wait gate green, check trusted-path ancestry, dispatch within 5 min,
|
||||
log `CANARY <run> <status>`) under `caffeinate -i`. Budget: 11 to 13 min per cell plus 15 min per gate,
|
||||
about 6 to 7 h total.
|
||||
|
||||
Per wave checks (same as Roll 1): transition verifier passes at migration-only and again at general
|
||||
with assignments carried; no `container die` fleet-wide; selector generation advances by exactly 2
|
||||
per cell. After the Asia cells: image census from MIG templates; every general cell on the new digest.
|
||||
|
||||
Failure handling: a failed canary re-enters through `mode=rollback` with rollback-digest = desired
|
||||
image (Roll 1 c27 pattern). A gate freeze on an Asia latency probe despite the 4 000 ms bar is a
|
||||
stop-and-investigate, not a retry. Monitor-side freezes (freshness, continuity deadline) re-gate
|
||||
after a 2 min back-off; the chain does this on its own.
|
||||
|
||||
Record every gate and wave in the findings doc as in Roll 1.
|
||||
|
||||
## Phase 3. After the roll (spread over the following week)
|
||||
|
||||
- **4.4 Recalibrate the retries bar.** After one week of `orca_relay_postgres_transaction_retry`
|
||||
on the new image, re-derive the `postgres_retries` monitor threshold from the new baseline
|
||||
(PR against `cloud/apps/relay-ops/src/incident-monitor.ts` thresholds). About 2 h.
|
||||
- **1.2 Pruner budget.** Raise `auth_token_pruner_max_rows_per_run` to the default 200k after a
|
||||
clean day; watch Cloud SQL write MB/s and the checkpoint alert. Then **1.5** log metric plus
|
||||
policy on `stopReason != complete`.
|
||||
- **1.3 Reclaim.** Once pruner runs delete ~0 rows: `pg_repack -t refresh_tokens` off-peak (check
|
||||
`pg_available_extensions` first; not `VACUUM FULL`). Confirm table, index, and `disk/utilization`
|
||||
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.
|
||||
- Update the checklist status header; tick 2.3, 4.1, 4.3 relay-side as deployed.
|
||||
|
||||
## Deferred, owner decision required
|
||||
|
||||
- **2.1 Private IP** (orca-cloud #477). One-way door with a Cloud SQL restart. When chosen: apply the
|
||||
foundation off-peak, then a template-only change that sets the `--private-ip` proxy flag. That is
|
||||
another cell roll unless bundled with a future image.
|
||||
- **5.2 Paging channel** for auth alerts: needs a destination.
|
||||
- **Parallel cell rolls** (2 or 3 at a time): about 1.5 days (relax exact-selector assertions to
|
||||
"exact except in-flight", single coordinator Terraform apply, parallel job shape, tests). Only
|
||||
worth building if more image rolls are planned after Roll 2, and only once the per-cell locks are
|
||||
live so a multi-cell reconnect burst is safe.
|
||||
- **2.2 Database split**: deferred to ~2026-11-01.
|
||||
|
||||
## Not in this plan
|
||||
|
||||
Desktop and mobile changes already merged (#18719 desktop early-window jitter and no same-token
|
||||
refresh retry; #18565 mobile fail-fast once merged) ship with the next desktop and mobile releases
|
||||
on their own schedules. No relay action needed.
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "../node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [],
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "app-store-performance",
|
||||
"specifier": "../config/oxlint-plugins/app-store-performance.mjs"
|
||||
},
|
||||
{
|
||||
"name": "quadratic-buffer-concat",
|
||||
"specifier": "../config/oxlint-plugins/quadratic-buffer-concat.mjs"
|
||||
},
|
||||
{
|
||||
"name": "sort-comparator-performance",
|
||||
"specifier": "../config/oxlint-plugins/sort-comparator-performance.mjs"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"app-store-performance/require-selector": "warn",
|
||||
"app-store-performance/no-identity-selector": "warn",
|
||||
"app-store-performance/no-fresh-selector-result": "warn",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "warn",
|
||||
"sort-comparator-performance/no-repeated-collator": "warn"
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "**/*.test.*", "**/*.spec.*"]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'ArrowFunctionExpression',
|
||||
'FunctionExpression',
|
||||
'FunctionDeclaration'
|
||||
])
|
||||
|
||||
function propertyName(node) {
|
||||
if (node?.type !== 'MemberExpression') {
|
||||
return null
|
||||
}
|
||||
if (!node.computed && node.property.type === 'Identifier') {
|
||||
return node.property.name
|
||||
}
|
||||
return node.property.type === 'Literal' ? node.property.value : null
|
||||
}
|
||||
|
||||
function isInlineSortComparator(node) {
|
||||
for (let parent = node.parent; parent; parent = parent.parent) {
|
||||
if (!FUNCTION_TYPES.has(parent.type)) {
|
||||
continue
|
||||
}
|
||||
const call = parent.parent
|
||||
return (
|
||||
call?.type === 'CallExpression' &&
|
||||
call.arguments[0] === parent &&
|
||||
['sort', 'toSorted'].includes(propertyName(call.callee))
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCollatorConstruction(node) {
|
||||
return (
|
||||
node.callee?.object?.type === 'Identifier' &&
|
||||
node.callee.object.name === 'Intl' &&
|
||||
propertyName(node.callee) === 'Collator'
|
||||
)
|
||||
}
|
||||
|
||||
function createRule(context) {
|
||||
function inspect(node) {
|
||||
const optionedComparison =
|
||||
node.type === 'CallExpression' &&
|
||||
propertyName(node.callee) === 'localeCompare' &&
|
||||
node.arguments.length >= 3
|
||||
if ((optionedComparison || isCollatorConstruction(node)) && isInlineSortComparator(node)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Create one Intl.Collator before sorting and reuse its compare method; resolving collation options inside the comparator repeats setup for every comparison. Preserve the locale, options, and tie-breaker.'
|
||||
})
|
||||
}
|
||||
}
|
||||
return { CallExpression: inspect, NewExpression: inspect }
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'sort-comparator-performance' },
|
||||
rules: { 'no-repeated-collator': { create: createRule } }
|
||||
}
|
||||
@@ -27,15 +27,424 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..738775f6fcdfb676054386fe34c0380327ed1863 100644
|
||||
--- a/src/process.cc
|
||||
+++ b/src/process.cc
|
||||
@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
process_info.push_back(std::move(pinfo));
|
||||
process_count++;
|
||||
}
|
||||
@@ -1,108 +1,112 @@
|
||||
-/*---------------------------------------------------------------------------------------------
|
||||
- * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
- * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
- *--------------------------------------------------------------------------------------------*/
|
||||
-
|
||||
-#include "process.h"
|
||||
-#include "process_commandline.h"
|
||||
-
|
||||
-#include <tlhelp32.h>
|
||||
-#include <psapi.h>
|
||||
-#include <limits>
|
||||
-
|
||||
-uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
- DWORD process_data_flags) {
|
||||
- // Fetch the PID and PPIDs
|
||||
- PROCESSENTRY32 process_entry = { 0 };
|
||||
- DWORD parent_pid = 0;
|
||||
- uint32_t process_count = 0;
|
||||
- HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
- process_entry.dwSize = sizeof(PROCESSENTRY32);
|
||||
- if (Process32First(snapshot_handle, &process_entry)) {
|
||||
- do {
|
||||
- if (process_entry.th32ProcessID != 0) {
|
||||
- ProcessInfo pinfo;
|
||||
- pinfo.pid = process_entry.th32ProcessID;
|
||||
- pinfo.ppid = process_entry.th32ParentProcessID;
|
||||
-
|
||||
- if (MEMORY & process_data_flags) {
|
||||
- GetProcessMemoryUsage(pinfo);
|
||||
- }
|
||||
-
|
||||
- if (COMMANDLINE & process_data_flags) {
|
||||
- GetProcessCommandLine(pinfo);
|
||||
- }
|
||||
-
|
||||
- strcpy(pinfo.name, process_entry.szExeFile);
|
||||
- process_info.push_back(std::move(pinfo));
|
||||
- process_count++;
|
||||
- }
|
||||
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
||||
- }
|
||||
-
|
||||
- CloseHandle(snapshot_handle);
|
||||
- return process_count;
|
||||
-}
|
||||
-
|
||||
-void GetProcessMemoryUsage(ProcessInfo& process_info) {
|
||||
- DWORD pid = process_info.pid;
|
||||
- HANDLE hProcess;
|
||||
- PROCESS_MEMORY_COUNTERS pmc;
|
||||
-
|
||||
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
||||
-
|
||||
- if (hProcess == NULL) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) {
|
||||
- process_info.memory = (DWORD)pmc.WorkingSetSize;
|
||||
- }
|
||||
-
|
||||
- CloseHandle(hProcess);
|
||||
-}
|
||||
-
|
||||
-// Per documentation, it is not recommended to add or subtract values from the FILETIME
|
||||
-// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows.
|
||||
-// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead.
|
||||
-// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx
|
||||
-ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) {
|
||||
- ULARGE_INTEGER kt, ut;
|
||||
- kt.LowPart = (*kernelTime).dwLowDateTime;
|
||||
- kt.HighPart = (*kernelTime).dwHighDateTime;
|
||||
-
|
||||
- ut.LowPart = (*userTime).dwLowDateTime;
|
||||
- ut.HighPart = (*userTime).dwHighDateTime;
|
||||
-
|
||||
- return kt.QuadPart + ut.QuadPart;
|
||||
-}
|
||||
-
|
||||
-void GetCpuUsage(Cpu& cpu_info, bool first_pass) {
|
||||
- DWORD pid = cpu_info.pid;
|
||||
- HANDLE hProcess;
|
||||
-
|
||||
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
||||
-
|
||||
- if (hProcess == NULL) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- FILETIME creationTime, exitTime, kernelTime, userTime;
|
||||
- FILETIME sysIdleTime, sysKernelTime, sysUserTime;
|
||||
- if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)
|
||||
- && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) {
|
||||
- if (first_pass) {
|
||||
- cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime);
|
||||
- cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
- } else {
|
||||
- ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime);
|
||||
- ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
-
|
||||
- cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime);
|
||||
- }
|
||||
- } else {
|
||||
- cpu_info.cpu = std::numeric_limits<double>::quiet_NaN();
|
||||
- }
|
||||
-
|
||||
- CloseHandle(hProcess);
|
||||
+/*---------------------------------------------------------------------------------------------
|
||||
+ * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
+ * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
+ *--------------------------------------------------------------------------------------------*/
|
||||
+
|
||||
+#include "process.h"
|
||||
+#include "process_commandline.h"
|
||||
+
|
||||
+#include <tlhelp32.h>
|
||||
+#include <psapi.h>
|
||||
+#include <limits>
|
||||
+
|
||||
+uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info,
|
||||
+ DWORD process_data_flags) {
|
||||
+ // Fetch the PID and PPIDs
|
||||
+ PROCESSENTRY32 process_entry = { 0 };
|
||||
+ DWORD parent_pid = 0;
|
||||
+ uint32_t process_count = 0;
|
||||
+ HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
+ process_entry.dwSize = sizeof(PROCESSENTRY32);
|
||||
+ if (Process32First(snapshot_handle, &process_entry)) {
|
||||
+ do {
|
||||
+ if (process_entry.th32ProcessID != 0) {
|
||||
+ // Value-initialize: `memory` is otherwise stack garbage when the flag is unset.
|
||||
+ ProcessInfo pinfo{};
|
||||
+ pinfo.pid = process_entry.th32ProcessID;
|
||||
+ pinfo.ppid = process_entry.th32ParentProcessID;
|
||||
+
|
||||
+ if (MEMORY & process_data_flags) {
|
||||
+ GetProcessMemoryUsage(pinfo);
|
||||
+ }
|
||||
+
|
||||
+ if (COMMANDLINE & process_data_flags) {
|
||||
+ GetProcessCommandLine(pinfo);
|
||||
+ }
|
||||
+
|
||||
+ strcpy(pinfo.name, process_entry.szExeFile);
|
||||
+ process_info.push_back(std::move(pinfo));
|
||||
+ process_count++;
|
||||
+ }
|
||||
+ } while (Process32Next(snapshot_handle, &process_entry));
|
||||
}
|
||||
|
||||
CloseHandle(snapshot_handle);
|
||||
+ }
|
||||
+
|
||||
+ CloseHandle(snapshot_handle);
|
||||
+ return process_count;
|
||||
+}
|
||||
+
|
||||
+void GetProcessMemoryUsage(ProcessInfo& process_info) {
|
||||
+ DWORD pid = process_info.pid;
|
||||
+ HANDLE hProcess;
|
||||
+ PROCESS_MEMORY_COUNTERS pmc;
|
||||
+
|
||||
+ // PROCESS_VM_READ is never used here -- GetProcessMemoryInfo reads counters the
|
||||
+ // kernel keeps, not the address space -- and acquiring it is what EDR scores.
|
||||
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
+
|
||||
+ if (hProcess == NULL) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) {
|
||||
+ process_info.memory = (DWORD)pmc.WorkingSetSize;
|
||||
+ }
|
||||
+
|
||||
+ CloseHandle(hProcess);
|
||||
+}
|
||||
+
|
||||
+// Per documentation, it is not recommended to add or subtract values from the FILETIME
|
||||
+// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows.
|
||||
+// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead.
|
||||
+// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx
|
||||
+ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) {
|
||||
+ ULARGE_INTEGER kt, ut;
|
||||
+ kt.LowPart = (*kernelTime).dwLowDateTime;
|
||||
+ kt.HighPart = (*kernelTime).dwHighDateTime;
|
||||
+
|
||||
+ ut.LowPart = (*userTime).dwLowDateTime;
|
||||
+ ut.HighPart = (*userTime).dwHighDateTime;
|
||||
+
|
||||
+ return kt.QuadPart + ut.QuadPart;
|
||||
+}
|
||||
+
|
||||
+void GetCpuUsage(Cpu& cpu_info, bool first_pass) {
|
||||
+ DWORD pid = cpu_info.pid;
|
||||
+ HANDLE hProcess;
|
||||
+
|
||||
+ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION.
|
||||
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
||||
+
|
||||
+ if (hProcess == NULL) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ FILETIME creationTime, exitTime, kernelTime, userTime;
|
||||
+ FILETIME sysIdleTime, sysKernelTime, sysUserTime;
|
||||
+ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)
|
||||
+ && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) {
|
||||
+ if (first_pass) {
|
||||
+ cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime);
|
||||
+ cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
+ } else {
|
||||
+ ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime);
|
||||
+ ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime);
|
||||
+
|
||||
+ cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime);
|
||||
+ }
|
||||
+ } else {
|
||||
+ cpu_info.cpu = std::numeric_limits<double>::quiet_NaN();
|
||||
+ }
|
||||
+
|
||||
+ CloseHandle(hProcess);
|
||||
}
|
||||
\ No newline at end of file
|
||||
diff --git a/src/process_commandline.cc b/src/process_commandline.cc
|
||||
index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3210c3cfd 100644
|
||||
--- a/src/process_commandline.cc
|
||||
+++ b/src/process_commandline.cc
|
||||
@@ -1,67 +1,125 @@
|
||||
-/*---------------------------------------------------------------------------------------------
|
||||
- * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
- * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
- *--------------------------------------------------------------------------------------------*/
|
||||
-
|
||||
-#include "process.h"
|
||||
-#include "process_commandline.h"
|
||||
-#include <windows.h>
|
||||
-#include <winternl.h>
|
||||
-#include <iostream>
|
||||
-
|
||||
-bool GetProcessCommandLine(ProcessInfo& process_info) {
|
||||
- HINSTANCE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
- if (!ntdll) {
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- decltype(NtQueryInformationProcess)* nt_query_information_process =
|
||||
- reinterpret_cast<decltype(NtQueryInformationProcess)*>(
|
||||
- GetProcAddress(ntdll, "NtQueryInformationProcess"));
|
||||
-
|
||||
- if (!nt_query_information_process) {
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- PROCESS_BASIC_INFORMATION pbi{};
|
||||
- PEB peb = {NULL};
|
||||
- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL};
|
||||
-
|
||||
- // Get process handle
|
||||
- DWORD pid = process_info.pid;
|
||||
- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
|
||||
- if (hProcess == INVALID_HANDLE_VALUE) {
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- // Get Process Environment Block (PEB)
|
||||
- NTSTATUS status = nt_query_information_process(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
|
||||
- if (NT_SUCCESS(status) && pbi.PebBaseAddress) {
|
||||
- // Read PEB
|
||||
- if (ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr)) {
|
||||
- // Read the processs parameters
|
||||
- if (ReadProcessMemory(hProcess, peb.ProcessParameters, &process_parameters, sizeof(RTL_USER_PROCESS_PARAMETERS), nullptr)) {
|
||||
- if (process_parameters.CommandLine.Length > 0) {
|
||||
- std::wstring buffer;
|
||||
- buffer.resize(process_parameters.CommandLine.Length / sizeof(wchar_t));
|
||||
- if (ReadProcessMemory(hProcess, process_parameters.CommandLine.Buffer, &buffer[0], process_parameters.CommandLine.Length, nullptr)) {
|
||||
- int wide_length = static_cast<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;
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- CloseHandle(hProcess);
|
||||
- return false;
|
||||
-}
|
||||
+/*---------------------------------------------------------------------------------------------
|
||||
+ * Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
+ * Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
+ *--------------------------------------------------------------------------------------------*/
|
||||
+
|
||||
+#include "process.h"
|
||||
+#include "process_commandline.h"
|
||||
+#include <windows.h>
|
||||
+#include <winternl.h>
|
||||
+#include <vector>
|
||||
+
|
||||
+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
|
||||
+
|
||||
+bool GetProcessCommandLine(ProcessInfo& process_info) {
|
||||
+ NtQueryInformationProcessFn query = NtQueryInformationProcessEntry();
|
||||
+ if (!query) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_info.pid);
|
||||
+ if (process == NULL) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ ULONG size = 0;
|
||||
+ NTSTATUS status = query(process, kProcessCommandLineInformation, nullptr, 0, &size);
|
||||
+ if (NT_SUCCESS(status)) {
|
||||
+ // Nothing was written, so there is no command line to read.
|
||||
+ CloseHandle(process);
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (status != kStatusInfoLengthMismatch && status != kStatusBufferTooSmall) {
|
||||
+ CloseHandle(process);
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (size < sizeof(UNICODE_STRING) || size > kMaxCommandLineBytes) {
|
||||
+ CloseHandle(process);
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ std::vector<unsigned char> buffer(size);
|
||||
+ status = query(process, kProcessCommandLineInformation, &buffer[0], size, &size);
|
||||
+ CloseHandle(process);
|
||||
+ if (!NT_SUCCESS(status)) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ // Header and characters arrive in one allocation, but treat the header as
|
||||
+ // untrusted: a hooked ntdll is the case this reader is written for, and an
|
||||
+ // unchecked Buffer/Length here would be an over-read encoded straight into JS.
|
||||
+ // Bound against buffer.size(), never `size` -- the second query overwrote it.
|
||||
+ const UNICODE_STRING* command_line = reinterpret_cast<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;
|
||||
+ }
|
||||
+
|
||||
+ // 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));
|
||||
+}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Performance regression checks
|
||||
|
||||
`pnpm --silent audit:perf > performance-audit.json` scans production `src/` with
|
||||
the existing app-store and buffer-concatenation rules plus the sort-comparator
|
||||
rule. Warnings are advisory in this full inventory; tool/parser failures fail.
|
||||
New warning findings on changed lines fail `pnpm check:code-quality:changed`.
|
||||
Tests, generated files, `mobile/` and `cloud/` are outside this source audit.
|
||||
|
||||
The sort rule detects optioned `localeCompare` and `Intl.Collator` construction
|
||||
inside inline `sort`/`toSorted` callbacks. Construct one collator outside the
|
||||
callback, preserving locale, options and tie-breakers. If the locale changes at
|
||||
runtime, reconstruct at the next sort or key the cache by locale. Bare comparisons
|
||||
and standalone equality checks are allowed. There is no autofix or interprocedural
|
||||
analysis: named comparators, aliases, custom methods and deferred callbacks need
|
||||
manual review. A warning identifies repeated setup, not proof of visible lag.
|
||||
|
||||
`pnpm test:perf:contracts` runs the explicit selection in
|
||||
`vitest.performance.config.ts`: SQLite statement reuse and schema parity, relay
|
||||
filesystem concurrency, tokenizer rejection, highlighting cache, queued
|
||||
cancellation, terminal backing-memory retention and detector fixtures. Missing
|
||||
listed files fail configuration loading. Tests run serially, without retries,
|
||||
and inherit the full suite's setup and forced-GC support. This makes existing
|
||||
regression coverage easy to run and attribute; it does not create new workload
|
||||
coverage by itself.
|
||||
|
||||
`.github/workflows/performance-contracts.yml` runs daily and manually on Linux,
|
||||
macOS and Windows, and on PRs changing this tooling or any listed contract file.
|
||||
It uploads per-OS JSON test results, plus the source inventory once from Linux
|
||||
because that scan is OS-independent. Its schedule starts after merge. Run the existing
|
||||
`test:e2e:terminal-perf:scale:report` for rendered typing/frame budgets and
|
||||
`test:e2e:ssh-docker-perf` for real transport behavior. Relay unit tests do not
|
||||
measure SSH RTT, WSL scheduling or a packaged Electron renderer.
|
||||
|
||||
To extend coverage, select a production-path regression with an operation-count,
|
||||
identity, queue-admission or retained-memory oracle. Confirm it fails with the
|
||||
old behavior. Use controlled, counterbalanced benchmark samples for timings;
|
||||
avoid new machine-dependent millisecond gates in the normal unit suite. A green
|
||||
source scan and these contracts cannot establish that the whole app is fast.
|
||||
@@ -10,6 +10,102 @@
|
||||
}
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "terminal-output.prestarted-shell-snapshot-adoption",
|
||||
"title": "Prestarted shell adoption paints covered output once",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-transport-and-live-electron",
|
||||
"surfaces": [
|
||||
"backend-created first terminal",
|
||||
"daemon snapshot adoption",
|
||||
"deferred live output"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "wsl", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos", "linux", "windows"],
|
||||
"coveredProviders": ["local", "daemon", "wsl"],
|
||||
"coverageNotes": "macOS daemon-backed Electron journey verifies same PID and terminal identity plus rendered output. Focused renderer contracts pass on Linux, Windows and WSL. Neighboring SSH model and replay contracts pass locally; no new live SSH or paired-runtime journey.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/user-attachments/assets/e8c6d1dc-6150-4c3d-b55a-3d12efefdd04",
|
||||
"https://github.com/user-attachments/assets/b0328f88-34ac-4d51-8119-9efe17072435"
|
||||
],
|
||||
"invariant": "Adopting a prestarted terminal preserves its existing process and paints snapshot-covered startup output once while retaining subsequent live output. Missing sequence proof or blank snapshots must not authorize dropping output.",
|
||||
"oracle": "Pass snapshot sequence and proven zero keyboard flags through real IPC transport projection. Deliver snapshot-covered and newer output before reattach resolves; drain replay parse callbacks and require one startup marker and the newer output. Repeat with no sequence and blank snapshot to retain unproven bytes. In Electron select a prestarted workspace, type a generated marker and compare PID and stable terminal identities before and after.",
|
||||
"commands": [
|
||||
"pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts",
|
||||
"pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts",
|
||||
"pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts",
|
||||
"assertions": [
|
||||
"zero and nonzero snapshot sequence and proven zero keyboard flags survive IPC projection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts",
|
||||
"assertions": [
|
||||
"startup output covered by the snapshot is painted once",
|
||||
"new output remains visible",
|
||||
"legacy unsequenced and blank snapshots retain bytes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.34,
|
||||
"summary": "5 suites / 48 tests pass. Focused 2-suite runs independently pass 27 tests on Linux, Windows and WSL."
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.67,
|
||||
"summary": "Broader connection/transport gate: 77 files and 813 tests passed, including neighboring restore, reconnect, input and replay behavior. Log: artifacts/worktree-create/orca-draft-replay-broader-gate.log."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "focused renderer transport and deferred-adoption contracts"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Focused local and remote runs pass; no CI soak history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Metadata tests fail before forwarding. Corrected parse-draining regression observes two startup markers when the baseline installation is removed, and one after restoration. Initial missing-live-output failure was a harness parse-drain omission and is not red proof. Before/fixed Electron screenshots show duplicate/single startup output."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Reuses existing snapshot baseline reconciliation with no new scan, timer or subprocess. Corrected daemon-backed rendered trial reaches replay at 116.6 ms and generated keyboard output at 177 ms after selecting the prestarted workspace. This measures selection/adoption, not ordinary composer creation."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Meet manifest CI and soak policy.",
|
||||
"Retain intentional-break and rendered identity/output proof.",
|
||||
"Exercise live SSH and paired-runtime snapshot adoption before claiming full provider coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Composer draft creation and cancellation are not implemented by this gate.",
|
||||
"No new live SSH, Windows or WSL UI run; remote evidence is focused contract tests.",
|
||||
"Mixed-version snapshots without sequence proof intentionally retain legacy behavior."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote if adoption duplicates covered output, drops newer or unproven output, changes terminal ownership, or flakes without explanation."
|
||||
},
|
||||
{
|
||||
"id": "cmd-j-tabs.host-qualified-candidate-ownership",
|
||||
"title": "Cmd-J tab candidates retain execution-host ownership",
|
||||
@@ -17922,6 +18018,139 @@
|
||||
"The sentinel changes a pane title within an existing layout; concurrent split and close conflicts remain separate coverage."
|
||||
],
|
||||
"demotionRule": "Demote if a failed push suppresses an identical retry, a successful equal write resumes redundant churn, or the routed observer journey flakes without a diagnosed cause."
|
||||
},
|
||||
{
|
||||
"id": "ssh.docker-recovery-and-resource-lifecycle",
|
||||
"title": "Docker SSH reconnect, host faults, listing and watcher lifecycle",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "electron-docker-ssh",
|
||||
"surfaces": [
|
||||
"SSH terminal recovery",
|
||||
"SSH remote resource ownership",
|
||||
"remote file listing",
|
||||
"remote explorer watcher recovery",
|
||||
"Electron test process cleanup"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh"],
|
||||
"coverageNotes": "A macOS Electron client drives a Linux Docker SSH execution host. The six-spec suite passed ten enabled cases with clean worker exit (5.2m). The formerly skipped frozen-host input case now waits for recovered authority before sending input and passed four separate executions (one initial and three repetitions). The existing flooded-shell fixme remains an explicitly reproduced application gap.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/18018",
|
||||
"https://github.com/stablyai/orca/pull/18546",
|
||||
"https://github.com/stablyai/orca/issues/12547"
|
||||
],
|
||||
"invariant": "Transport loss and frozen-host silence must preserve the remote session; host relay loss may rebind a pane without accumulating reattachable leases. Reconnects must preserve usable terminal content, bounded PTYs/fds/processes, complete large listings, and independently recoverable watcher processes. Electron test shutdown must release inherited pipes after confirmed root exit without closing live-process pipes.",
|
||||
"oracle": "Poll a changed connected SSH authority after injected faults, then require terminal output and appropriate PTY identity. Read remote process/fd state, listFiles replies, and rendered explorer rows. Resolve Playwright cleanup only after the root process exits and its inherited pipes close; live-process pipes remain untouched.",
|
||||
"commands": [
|
||||
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.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-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"tests/e2e/ssh-docker-transport-drop-recovery.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-resource-accumulation.spec.ts",
|
||||
"tests/e2e/ssh-docker-watcher-isolation.spec.ts",
|
||||
"tests/e2e/helpers/electron-process-shutdown.unit.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts",
|
||||
"assertions": [
|
||||
"preserves transport-drop PTY and scrollback, replaces relay-loss binding, and keeps one reattachable lease per pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-docker-half-open-link.spec.ts",
|
||||
"assertions": [
|
||||
"leaves connected after host freeze and renders process-produced output after recovery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts",
|
||||
"assertions": [
|
||||
"returns both a bounded client page and a complete legacy-client remote listing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts",
|
||||
"assertions": [
|
||||
"restores shell scrollback and full-screen output and opens a usable fresh tab"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-docker-resource-accumulation.spec.ts",
|
||||
"assertions": [
|
||||
"keeps remote pts devices, relay fds, process counts and inherited master fds bounded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/ssh-docker-watcher-isolation.spec.ts",
|
||||
"assertions": [
|
||||
"keeps rendered explorer changes and terminal output live after watcher crash and repairs a deleted watcher artifact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
|
||||
"assertions": [
|
||||
"releases inherited pipes after confirmed exit, including prior exit",
|
||||
"retains live-process pipes on shutdown timeout"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
|
||||
"durationSeconds": 0.168,
|
||||
"summary": "All three shutdown regression tests passed; disabling pipe release fails the first two by timeout. Two half-open Electron repetitions separately passed in 1.7m without worker teardown timeout."
|
||||
},
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.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-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"durationSeconds": 312,
|
||||
"summary": "Six specs: ten passed, two existing fixme skipped, clean worker shutdown. Baseline same enabled suite: ten passed but worker teardown timed out (7.3m)."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 420,
|
||||
"scope": "per Electron Docker test; measured suite p95 and CI soak not yet established"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "flaky",
|
||||
"evidence": "Baseline: ten enabled tests passed, two fixme skipped, worker teardown timed out (7.3m). After pipe cleanup: ten passed and worker exited cleanly (5.2m); two half-open repeats passed (1.7m). The formerly skipped thaw-input case failed before its recovered-authority wait and passed 1+3 executions afterward (56.9s + 2.6m). Flood failed both its original input oracle and a strengthened producer-completion oracle after recovery."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Disabling exited-process pipe release causes two shutdown contract tests to time out; restoring it passes 3/3. Baseline Docker worker teardown failed; final six-spec enabled run and half-open repeats exit successfully. Frozen-host input fails without the post-thaw recovered-authority wait and passes four runs with it. Full product fault/recovery mutation coverage and CI history remain missing."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Test-only bounded pipe destruction and authority polling; no production polling, subprocesses, or runtime work added. Remote resources are counted instead of using wall-clock leak thresholds."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Require complete six-spec repeat runs with clean worker shutdown.",
|
||||
"Resolve the remaining #18018 flooded-shell reproduction and remove its fixme marker.",
|
||||
"Collect CI runtime and flake history plus product red/green evidence before blocking."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The disconnected 48MB flood still loses its relay channel: original post-flood input marker failed in 60s, and waiting for the finite producer completion marker failed in 120s. It remains an explicit #18018 fixme reproduction; frozen-host input is re-enabled after four successful runs.",
|
||||
"Linux and Windows desktop clients, WSL, folder workspaces, paired runtimes and live agent CLIs are not exercised by these Docker specs.",
|
||||
"Some legacy assertions inspect terminal serialization or backing state rather than rendered DOM; no blanket visual coverage claim.",
|
||||
"No p95 CI history or full product mutation proof."
|
||||
],
|
||||
"demotionRule": "Keep experimental while any recovery reproduction fails or any teardown, identity, resource-count, or rendered oracle flakes; never promote by extending sleeps or retries."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,13 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (processCc.includes('OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ')) {
|
||||
throw new Error(
|
||||
'src/process.cc still takes PROCESS_VM_READ for memory or CPU counters it never reads ' +
|
||||
'from the address space. pnpm did not apply ' +
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
@@ -123,6 +132,13 @@ function applyWindowsProcessTreeBuildFixes() {
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
// The memory and CPU readers only ever call GetProcessMemoryInfo/GetProcessTimes,
|
||||
// which need no more than PROCESS_QUERY_LIMITED_INFORMATION; taking VM_READ is
|
||||
// what EDR scores.
|
||||
processCc = processCc.replaceAll(
|
||||
'OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid)',
|
||||
'OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)'
|
||||
)
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
@@ -131,7 +147,8 @@ function applyWindowsProcessTreeBuildFixes() {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR)
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess || repairedCommandLine) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
@@ -173,6 +190,14 @@ function main() {
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`node-gyp reported success but ${built} is missing.`)
|
||||
}
|
||||
// Why check the artifact and not only the source: the source checks above run
|
||||
// before node-gyp, and a stale build directory can outlive them.
|
||||
if (inspectWindowsProcessTreeAddon(built) === 'unpatched') {
|
||||
throw new Error(
|
||||
'The built addon still calls ReadProcessMemory, so it did not come from the patched ' +
|
||||
'command-line reader. A relay would get the primitive MDE scores as credential dumping.'
|
||||
)
|
||||
}
|
||||
const machine = readPeMachine(built)
|
||||
if (machine !== PE_MACHINE[arch]) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const steps = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8'))
|
||||
.runs.steps
|
||||
const toolchain = steps.find((step) => step.name === 'Use external node-gyp')
|
||||
|
||||
describe('CI native toolchain preparation', () => {
|
||||
it('probes only after both cache restore variants and before native rebuilding', () => {
|
||||
const index = steps.indexOf(toolchain)
|
||||
for (const id of ['native-cache-restore', 'native-cache-restore-only']) {
|
||||
expect(index).toBeGreaterThan(steps.findIndex((step) => step.id === id))
|
||||
expect(toolchain.env.NATIVE_CACHE_HIT).toContain(`steps.${id}.outputs.cache-hit`)
|
||||
}
|
||||
expect(index).toBeLessThan(steps.findIndex((step) => step.name === 'Prepare native runtime'))
|
||||
expect(toolchain.if).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
|
||||
})
|
||||
|
||||
// The action's toolchain workaround only runs in Linux Bash.
|
||||
it.skipIf(process.platform === 'win32').each([
|
||||
['node', 'true', '0', false],
|
||||
['node', 'true', '1', true],
|
||||
['node', 'false', '0', true],
|
||||
['node', '', '0', true],
|
||||
['electron', 'true', '0', true],
|
||||
['electron', 'false', '0', true]
|
||||
])('runtime=%s cache=%s probe=%s installs=%s', (runtime, hit, probeStatus, installs) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-ci-native-toolchain-'))
|
||||
const log = join(directory, 'commands')
|
||||
const environment = join(directory, 'github-env')
|
||||
try {
|
||||
writeFileSync(log, '')
|
||||
writeFileSync(environment, '')
|
||||
for (const [name, source] of [
|
||||
['node', 'echo "node $*" >> "$COMMAND_LOG"\nexit "$PROBE_STATUS"'],
|
||||
['npm', 'echo "npm $*" >> "$COMMAND_LOG"\nif [ "$1" = root ]; then echo /global; fi']
|
||||
]) {
|
||||
const path = join(directory, name)
|
||||
writeFileSync(path, `#!/bin/sh\n${source}\n`)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
execFileSync('bash', ['-e', '-o', 'pipefail', '-c', toolchain.run], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH}`,
|
||||
NATIVE_RUNTIME: runtime,
|
||||
NATIVE_CACHE_HIT: hit,
|
||||
PROBE_STATUS: probeStatus,
|
||||
COMMAND_LOG: log,
|
||||
GITHUB_ENV: environment
|
||||
}
|
||||
})
|
||||
const commands = readFileSync(log, 'utf8')
|
||||
expect(commands.includes('npm install -g node-gyp@11.5.0')).toBe(installs)
|
||||
expect(commands.includes('node config/scripts/ensure-native-runtime.mjs --check-only')).toBe(
|
||||
runtime === 'node' && hit === 'true'
|
||||
)
|
||||
expect(readFileSync(environment, 'utf8')).toBe(
|
||||
installs ? 'npm_config_node_gyp=/global/node-gyp/bin/node-gyp.js\n' : ''
|
||||
)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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\}/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,12 @@ import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { release } from 'node:os'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import {
|
||||
ensureWindowsProcessTreeCommandLinePatch,
|
||||
inspectWindowsProcessTreeAddon,
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders,
|
||||
windowsProcessTreeAddonPath
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
@@ -253,11 +259,18 @@ function collectNativeModuleFailures() {
|
||||
|
||||
function loadNativeModule(moduleName) {
|
||||
if (moduleName === '@vscode/windows-process-tree') {
|
||||
// A bare require already loads the .node addon on win32, so it catches an
|
||||
// ABI mismatch on its own. What it cannot catch is a snapshot that comes
|
||||
// back empty -- the shape a blocked CreateToolhelp32Snapshot produces --
|
||||
// so check the addon actually enumerates before calling the runtime healthy.
|
||||
// A bare require loads the .node addon on win32, so it catches an ABI
|
||||
// mismatch on its own. What it cannot catch is *which* addon loaded: the
|
||||
// published tarball ships a prebuilt built from unpatched source that is
|
||||
// node-addon-api, so it requires cleanly and then reads every process's
|
||||
// command line out of its address space. Check the binary, not the load.
|
||||
require(moduleName)
|
||||
if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath()) === 'unpatched') {
|
||||
throw new Error(
|
||||
'the loaded addon still calls ReadProcessMemory, so it was not built from the patched ' +
|
||||
'source. Rebuild it (pnpm run rebuild:electron) rather than using the published prebuild.'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (moduleName === 'windows-native-registry') {
|
||||
@@ -368,6 +381,14 @@ function getWindowsBuildNumber() {
|
||||
function rebuildNodeRuntimeModules(moduleNames) {
|
||||
for (const moduleName of moduleNames) {
|
||||
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
|
||||
if (moduleName === '@vscode/windows-process-tree') {
|
||||
// Why before node-gyp: this module is rebuilt precisely because the
|
||||
// binary was the unpatched one, and pnpm materializes it unpatched often
|
||||
// enough that compiling the source as-is would just rebuild the same
|
||||
// reader and fail the verify pass.
|
||||
ensureWindowsProcessTreeCommandLinePatch(moduleDir)
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir)
|
||||
}
|
||||
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
|
||||
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
|
||||
if (moduleName === 'node-pty' && process.platform === 'win32') {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
@@ -27,7 +28,6 @@ describe('ensure-native-runtime', () => {
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
@@ -67,7 +67,6 @@ describe('ensure-native-runtime', () => {
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
@@ -102,7 +101,6 @@ describe('ensure-native-runtime', () => {
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
@@ -137,7 +135,6 @@ describe('ensure-native-runtime', () => {
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
@@ -171,7 +168,6 @@ describe('ensure-native-runtime', () => {
|
||||
const logPath = join(projectDir, 'native-runtime.log')
|
||||
const markerPath = join(projectDir, 'rebuilt.marker')
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writePatchedNodePtyBuildArtifacts(projectDir)
|
||||
@@ -198,7 +194,9 @@ describe('ensure-native-runtime', () => {
|
||||
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
// Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture
|
||||
// missing it fails every case with a module-resolution error instead of the defect under test.
|
||||
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { runProcess } from '../../src/shared/child-process/run-process'
|
||||
|
||||
const workflow = parse(
|
||||
readFileSync(new URL('../../.github/workflows/hourly-mac-build.yml', import.meta.url), 'utf8')
|
||||
)
|
||||
const preflight = workflow.jobs.preflight
|
||||
const freshness = preflight.steps.find((step) => step.id === 'freshness')
|
||||
const head = 'abcdef0123'.repeat(4)
|
||||
|
||||
async function checkFreshness(overrides = {}) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'hourly-preflight-'))
|
||||
const output = join(directory, 'output')
|
||||
try {
|
||||
const result = await runProcess({
|
||||
program: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
`gh() {
|
||||
case "$1 $2" in
|
||||
"api "*) printf '%s\\n' "$HEAD_SHA" ;;
|
||||
"release list") printf '%s\\n' "$LAST_TAG" ;;
|
||||
"release view") printf '%s\\n' "$LAST_SHA" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
${freshness.run}`
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_OUTPUT: output,
|
||||
GITHUB_REPOSITORY: 'stablyai/orca',
|
||||
MAIN_REPO_TOKEN: 'main-token',
|
||||
HOURLY_REPO: 'stablyai/orca-hourly',
|
||||
HEAD_SHA: head,
|
||||
LAST_TAG: 'previous-hourly',
|
||||
LAST_SHA: head.slice(0, 12),
|
||||
FORCED: 'false',
|
||||
...overrides
|
||||
}
|
||||
})
|
||||
return {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
stdout: result.stdout,
|
||||
output: result.code === 0 ? readFileSync(output, 'utf8') : ''
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('hourly build preflight', () => {
|
||||
it('gates Mac allocation and pins the checkout and downstream identity', () => {
|
||||
const build = workflow.jobs['build-hourly-mac']
|
||||
expect(preflight['runs-on']).toBe('ubuntu-latest')
|
||||
expect(preflight.steps.some((step) => step.uses?.startsWith('actions/checkout'))).toBe(false)
|
||||
expect(
|
||||
preflight.steps.find((step) => step.id === 'app_token').with['permission-contents']
|
||||
).toBe('read')
|
||||
expect(build.needs).toBe('preflight')
|
||||
expect(build.if).toBe("needs.preflight.outputs.should_build == 'true'")
|
||||
expect(build.steps.find((step) => step.name === 'Checkout').with.ref).toBe(
|
||||
build.outputs.head_sha
|
||||
)
|
||||
expect(build.outputs.head_sha).toBe('${{ needs.preflight.outputs.head_sha }}')
|
||||
expect(build.steps.find((step) => step.id === 'release').env.SHA).toBe(build.outputs.head_sha)
|
||||
expect(workflow.concurrency).toEqual({ group: 'hourly-mac-build', 'cancel-in-progress': false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unchanged', {}, false],
|
||||
['changed', { LAST_SHA: '123456789012' }, true],
|
||||
['forced', { FORCED: 'true' }, true],
|
||||
['first build', { LAST_TAG: '' }, true],
|
||||
['missing prior identity', { LAST_SHA: '' }, true]
|
||||
])('%s main selects the expected build decision', async (_name, env, shouldBuild) => {
|
||||
const result = await checkFreshness(env)
|
||||
expect(result.exitCode, `${result.stdout} ${result.stderr}`).toBe(0)
|
||||
expect(result.output).toBe(`head_sha=${head}\nshould_build=${shouldBuild}\n`)
|
||||
})
|
||||
|
||||
it('fails closed when main cannot be resolved, even when forced', async () => {
|
||||
const result = await checkFreshness({ HEAD_SHA: '', FORCED: 'true' })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
})
|
||||
@@ -213,12 +213,17 @@ const LINUX_PACKAGE_TESTS = [
|
||||
const WINDOWS_PACKAGE_TESTS = [
|
||||
...LINUX_PACKAGE_TESTS,
|
||||
'config/scripts/rebuild-native-deps.test.mjs',
|
||||
'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
'src/shared/child-process/windows-command-line.win32.test.ts',
|
||||
'src/shared/child-process/windows-cmd-shim-resolution.test.ts',
|
||||
'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts',
|
||||
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
|
||||
'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts',
|
||||
'src/main/windows/windows-pty-job.win32.test.ts',
|
||||
'src/main/windows/windows-host-job.win32.test.ts',
|
||||
'src/main/windows/windows-process-tree-command-line-patch.test.ts',
|
||||
'src/main/windows-live-tree-kill.win32.test.ts',
|
||||
'src/main/wsl/wsl-runner.test.ts',
|
||||
'src/main/wsl/wsl-guest-environment.test.ts',
|
||||
@@ -227,14 +232,18 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
'src/main/wsl/wsl-w1-w3-contract.test.ts',
|
||||
'src/shared/source-scan/source-tree-scan.test.ts',
|
||||
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
|
||||
'src/main/computer/desktop-script-runtime-host.win32.test.ts',
|
||||
'src/main/cursor/hook-service.test.ts',
|
||||
'src/main/orca-profiles/profile-index-store.test.ts',
|
||||
'src/main/startup/windows-install-dir-acl-repair.win32.test.ts',
|
||||
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
|
||||
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
|
||||
'src/shared/secure-file-fsync-flags.test.ts',
|
||||
'src/shared/secure-path-windows-acl.win32.test.ts',
|
||||
'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts',
|
||||
'src/main/ipc/pty-codex-account-attribution.test.ts',
|
||||
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
|
||||
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts',
|
||||
'src/relay/windows-port-scan.win32.test.ts'
|
||||
]
|
||||
|
||||
const DESKTOP_IRRELEVANT_PREFIXES = [
|
||||
|
||||
@@ -414,10 +414,11 @@ describe('PR Checks skip wiring', () => {
|
||||
})
|
||||
|
||||
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
|
||||
expect(prWorkflow.jobs['e2e-paths'].needs).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs['e2e-paths'].if).toBe(
|
||||
"github.event.pull_request.draft != true && needs.code_paths.outputs.should_run == 'true'"
|
||||
const filter = prWorkflow.jobs.code_paths.steps.find((step) => step.id === 'e2e_filter')
|
||||
expect(filter.if).toBe(
|
||||
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
|
||||
)
|
||||
expect(prWorkflow.jobs['e2e-paths']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets verify pass skipped jobs the classifier turned off', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const nativeImeSpec = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
|
||||
const filterStep = prWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.name === 'Filter changed E2E specs'
|
||||
)
|
||||
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
|
||||
@@ -106,16 +106,16 @@ describe('PR E2E gate contract', () => {
|
||||
// Why: without this the job could lose its filter and run on every PR — the
|
||||
// cost the path filter exists to avoid — while the gate assertions above
|
||||
// stay green.
|
||||
expect(prWorkflow.jobs.e2e.needs).toBe('e2e-paths')
|
||||
expect(prWorkflow.jobs.e2e.if).toBe("needs.e2e-paths.outputs.should_run == 'true'")
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
|
||||
'${{ steps.filter.outputs.should_run }}'
|
||||
expect(prWorkflow.jobs.e2e.needs).toBe('code_paths')
|
||||
expect(prWorkflow.jobs.e2e.if).toBe("needs.code_paths.outputs.e2e_should_run == 'true'")
|
||||
expect(prWorkflow.jobs.code_paths.outputs.e2e_should_run).toBe(
|
||||
'${{ steps.e2e_filter.outputs.should_run }}'
|
||||
)
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
|
||||
'${{ steps.filter.outputs.test_files }}'
|
||||
expect(prWorkflow.jobs.code_paths.outputs.test_files).toBe(
|
||||
'${{ steps.e2e_filter.outputs.test_files }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
|
||||
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
|
||||
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.code_paths.outputs.test_files }}')
|
||||
})
|
||||
|
||||
it('enforces every job verify depends on', () => {
|
||||
@@ -360,11 +360,11 @@ describe('PR E2E gate contract', () => {
|
||||
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
|
||||
|
||||
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
|
||||
'${{ steps.filter.outputs.ssh_source_changed }}'
|
||||
expect(prWorkflow.jobs.code_paths.outputs.ssh_source_changed).toBe(
|
||||
'${{ steps.e2e_filter.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
|
||||
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
|
||||
'${{ needs.code_paths.outputs.ssh_source_changed }}'
|
||||
)
|
||||
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
|
||||
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
|
||||
@@ -565,12 +565,12 @@ describe('PR E2E gate contract', () => {
|
||||
expect(prWorkflow.jobs.terminal_ime_native.uses).toBe(
|
||||
'./.github/workflows/terminal-ime-e2e.yml'
|
||||
)
|
||||
expect(prWorkflow.jobs.terminal_ime_native.needs).toBe('e2e-paths')
|
||||
expect(prWorkflow.jobs.terminal_ime_native.needs).toBe('code_paths')
|
||||
expect(prWorkflow.jobs.terminal_ime_native.if).toBe(
|
||||
"needs.e2e-paths.outputs.native_ime_source_changed == 'true'"
|
||||
"needs.code_paths.outputs.native_ime_source_changed == 'true'"
|
||||
)
|
||||
expect(prWorkflow.jobs['e2e-paths'].outputs.native_ime_source_changed).toBe(
|
||||
'${{ steps.filter.outputs.native_ime_source_changed }}'
|
||||
expect(prWorkflow.jobs.code_paths.outputs.native_ime_source_changed).toBe(
|
||||
'${{ steps.e2e_filter.outputs.native_ime_source_changed }}'
|
||||
)
|
||||
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --native-ime-source')
|
||||
expect(filterStep.run).toContain('native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED')
|
||||
@@ -636,13 +636,8 @@ describe('PR E2E gate contract', () => {
|
||||
.filter((spec) => nativeGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
|
||||
expect(nativeGatedSpecs.length).toBeGreaterThan(0)
|
||||
|
||||
// Why exempt: the digit repro needs a nested gnome-shell, which no hosted runner provides
|
||||
// (headless mutter never answers RemoteDesktop.CreateSession); the macOS spec needs a real
|
||||
// macOS input source, and no macOS runner exists on any PR or scheduled lane.
|
||||
const unreachableSpecs = new Set([
|
||||
'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts',
|
||||
'tests/e2e/terminal-macos-2set-korean-native.spec.ts'
|
||||
])
|
||||
// The macOS spec needs a native input source; PR and scheduled IME lanes use Linux.
|
||||
const unreachableSpecs = new Set(['tests/e2e/terminal-macos-2set-korean-native.spec.ts'])
|
||||
const unclaimed = nativeGatedSpecs.filter(
|
||||
(spec) => !unreachableSpecs.has(spec) && !nativeImeRunner.includes(spec)
|
||||
)
|
||||
@@ -682,8 +677,13 @@ describe('PR E2E gate contract', () => {
|
||||
|
||||
// Why pin the titles: the runner requires one receipt per name, so a rename that nobody
|
||||
// mirrored here would fail the lane loudly instead of quietly halving it.
|
||||
const nativeDigitSpec = readFileSync(
|
||||
join(projectDir, 'tests/e2e/terminal-hangul-terminating-digit-native.spec.ts'),
|
||||
'utf8'
|
||||
)
|
||||
expect(nativeDigitSpec).toContain('appendImeEngagementReceipt(testInfo.title, trace)')
|
||||
for (const title of EXPECTED_NATIVE_IME_TESTS) {
|
||||
expect(nativeImeSpec, title).toContain(title)
|
||||
expect(nativeImeSpec + nativeDigitSpec, title).toContain(title)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { hasNativeImeSourceChange, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const filterStep = workflow.jobs.code_paths.steps.find((step) => step.id === 'e2e_filter')
|
||||
|
||||
describe('native-only PR E2E routing', () => {
|
||||
it('avoids generic E2E allocation for native-only changes while preserving its IME lane', () => {
|
||||
for (const file of [
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
]) {
|
||||
expect(hasNativeImeSourceChange([file])).toBe(true)
|
||||
expect(shouldRunReusablePrE2e([file])).toBe(false)
|
||||
}
|
||||
expect(shouldRunReusablePrE2e([])).toBe(false)
|
||||
for (const spec of [
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/paired-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts',
|
||||
'tests/e2e/future.spec.ts'
|
||||
]) {
|
||||
expect(shouldRunReusablePrE2e([spec])).toBe(true)
|
||||
expect(shouldRunReusablePrE2e(['tests/e2e/terminal-ibus-hangul-native.spec.ts', spec])).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --reusable-workflow')
|
||||
expect(filterStep.run).toContain('if [ "$SHOULD_RUN" = true ]; then')
|
||||
})
|
||||
})
|
||||
@@ -218,6 +218,16 @@ export function hasNativeImeSourceChange(changedPaths) {
|
||||
).some((route) => changedPaths.some(route.matches))
|
||||
}
|
||||
|
||||
export function shouldRunReusablePrE2e(changedPaths) {
|
||||
// Native IME has its own workflow; SSH still runs inside the reusable workflow.
|
||||
return (
|
||||
hasSshSourceChange(changedPaths) ||
|
||||
selectPrE2eSpecs(changedPaths).some(
|
||||
(spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
@@ -227,6 +237,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
if (process.argv.includes('--ssh-source')) {
|
||||
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
|
||||
} else if (process.argv.includes('--reusable-workflow')) {
|
||||
process.stdout.write(`${shouldRunReusablePrE2e(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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -616,7 +616,7 @@ if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) {
|
||||
// Why: automation launches this app while someone is working; announce that the
|
||||
// window will come up without taking the foreground so the mode is visible in logs.
|
||||
if (!isHelpOrVersion && process.env.ORCA_BACKGROUND_LAUNCH === '1') {
|
||||
console.error('[orca-dev] Background launch: window shows without stealing focus')
|
||||
console.error('[orca-dev] Background launch: window stays off screen; automate through CDP')
|
||||
}
|
||||
let forwardedExtras = []
|
||||
if (!userPassedPort && !isHelpOrVersion) {
|
||||
|
||||
@@ -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,45 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
|
||||
|
||||
function lint(source) {
|
||||
return runOxlintPluginOnSource({
|
||||
pluginName: 'sort-comparator-performance',
|
||||
pluginPath: path.resolve('config/oxlint-plugins/sort-comparator-performance.mjs'),
|
||||
rules: { 'sort-comparator-performance/no-repeated-collator': 'warn' },
|
||||
source
|
||||
})
|
||||
}
|
||||
|
||||
describe('sort comparator performance', () => {
|
||||
it('reports repeated collation setup in inline sort and toSorted callbacks', () => {
|
||||
const findings = lint(`
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name, locale, { sensitivity: 'base' }))
|
||||
rows.toSorted(function (a, b) { return new Intl.Collator('sv').compare(a, b) })
|
||||
rows['sort']((a, b) => Intl.Collator('en', { numeric: true }).compare(a, b))
|
||||
rows.sort((a, b) => a['localeCompare'](b, undefined, options))
|
||||
`)
|
||||
expect(findings).toHaveLength(4)
|
||||
expect(
|
||||
findings.every(
|
||||
(finding) => finding.code === 'sort-comparator-performance(no-repeated-collator)'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows one collator per sort, bare comparisons, and unrelated callbacks', () => {
|
||||
expect(
|
||||
lint(`
|
||||
const collator = new Intl.Collator(locale, options)
|
||||
rows.sort((a, b) => collator.compare(a.name, b.name) || a.id.localeCompare(b.id))
|
||||
rows.toSorted(collator.compare)
|
||||
const equal = a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0
|
||||
rows.map(a => new Intl.Collator(a.locale))
|
||||
rows.sort((a, b) => {
|
||||
function deferred() { return new Intl.Collator(locale) }
|
||||
return a - b
|
||||
})
|
||||
`)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,44 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8'))
|
||||
|
||||
// Every step that mirrors this repo's whole ref namespace onto a runner disk to
|
||||
// prove a commit is reachable from a branch or tag before signing it.
|
||||
const REF_MIRRORS = [
|
||||
['.github/workflows/adhoc-mac-build.yml', 'build-adhoc-mac', 'Vet the requested ref'],
|
||||
['.github/workflows/dev-channel-win-build.yml', 'build-win', 'Vet the requested inputs']
|
||||
]
|
||||
|
||||
describe('ref-mirroring vet steps', () => {
|
||||
it('keeps the full-history adhoc checkout on the same case-safe backend', () => {
|
||||
const steps = readWorkflow('.github/workflows/adhoc-mac-build.yml').jobs['build-adhoc-mac']
|
||||
.steps
|
||||
const checkout = steps.find((step) => step.name === 'Checkout the requested ref')
|
||||
expect(checkout.env.GIT_DEFAULT_REF_FORMAT).toBe('reftable')
|
||||
expect(checkout.with.ref).toBe('${{ steps.vetted.outputs.sha }}')
|
||||
expect(checkout.with['fetch-depth']).toBe(0)
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
})
|
||||
|
||||
// Why: macOS and Windows runner disks are case-insensitive, and this repo has
|
||||
// branches that differ only in casing. The files backend cannot store both, and
|
||||
// it fails the whole fetch rather than the one ref — so the vet step dies before
|
||||
// any build runs. reftable keys refs in a table instead of file paths.
|
||||
it.each(REF_MIRRORS)(
|
||||
'%s creates its scratch repo with the reftable backend',
|
||||
(path, job, step) => {
|
||||
const run = readWorkflow(path).jobs[job].steps.find(
|
||||
(candidate) => candidate.name === step
|
||||
).run
|
||||
|
||||
expect(run).toContain('+refs/heads/*:refs/heads/*')
|
||||
expect(run).toMatch(/git init\b[^\n]*--ref-format=reftable/)
|
||||
expect(run).not.toMatch(/git init -q --bare "\$scratch"/)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { runProcess } from '../../src/shared/child-process/run-process'
|
||||
|
||||
const readWorkflow = (name) => parse(readFileSync(`.github/workflows/${name}.yml`, 'utf8'))
|
||||
const windowsVet = readWorkflow('dev-channel-win-build').jobs['build-win'].steps.find(
|
||||
(step) => step.id === 'vetted'
|
||||
)
|
||||
const macSteps = readWorkflow('adhoc-mac-build').jobs['build-adhoc-mac'].steps
|
||||
const macVet = macSteps.find((step) => step.id === 'vetted')
|
||||
const macCheckout = macSteps.find((step) => step.name === 'Checkout the requested ref')
|
||||
const directory = mkdtempSync(join(tmpdir(), 'workflow-ref-reachability-'))
|
||||
const repository = join(directory, 'remote.git')
|
||||
const identity = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Ref test',
|
||||
GIT_AUTHOR_EMAIL: 'ref-test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Ref test',
|
||||
GIT_COMMITTER_EMAIL: 'ref-test@example.com'
|
||||
}
|
||||
let ancestor, upper, lower, untrusted
|
||||
|
||||
async function git(args, env = identity) {
|
||||
const result = await runProcess({ program: 'git', args, env })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await git(['init', '--bare', '--ref-format=reftable', repository])
|
||||
const tree = await git(['-C', repository, 'mktree'])
|
||||
ancestor = await git(['-C', repository, 'commit-tree', tree, '-m', 'ancestor'])
|
||||
upper = await git(['-C', repository, 'commit-tree', tree, '-p', ancestor, '-m', 'upper'])
|
||||
lower = await git(['-C', repository, 'commit-tree', tree, '-p', ancestor, '-m', 'lower'])
|
||||
untrusted = await git(['-C', repository, 'commit-tree', tree, '-m', 'PR only'])
|
||||
for (const [ref, sha] of [
|
||||
['refs/heads/Fix', upper],
|
||||
['refs/heads/fix', lower],
|
||||
['refs/pull/1/head', untrusted]
|
||||
]) {
|
||||
await git(['-C', repository, 'update-ref', ref, sha])
|
||||
}
|
||||
await git(['-C', repository, 'tag', '-a', 'Release', upper, '-m', 'upper tag'])
|
||||
await git(['-C', repository, 'tag', '-a', 'release', lower, '-m', 'lower tag'])
|
||||
await git(['-C', repository, 'config', 'uploadpack.allowFilter', 'true'])
|
||||
})
|
||||
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
async function vet(step, ref) {
|
||||
const scratch = mkdtempSync(join(directory, 'attempt-'))
|
||||
const script = join(scratch, 'vet.sh')
|
||||
writeFileSync(script, step.run)
|
||||
return runProcess({
|
||||
program: 'bash',
|
||||
args: [script],
|
||||
env: {
|
||||
...identity,
|
||||
REPO_URL: pathToFileURL(repository).href,
|
||||
RUNNER_TEMP: scratch,
|
||||
GITHUB_OUTPUT: join(scratch, 'output'),
|
||||
REQUESTED_REF: ref,
|
||||
REQUESTED_SHA: ref,
|
||||
CHANNEL: 'hourly',
|
||||
TAG: 'v1.0.0-hourly.test',
|
||||
VERSION: '1.0.0-hourly.test'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('release ref trust with case-twin names', () => {
|
||||
it('accepts both branch tips, annotated tags, and their common ancestor', async () => {
|
||||
for (const sha of [upper, lower, ancestor]) {
|
||||
const result = await vet(windowsVet, sha)
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
for (const ref of ['Fix', 'fix', 'Release', 'release', ancestor]) {
|
||||
const result = await vet(macVet, ref)
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects PR-only commits even when the server has their objects', async () => {
|
||||
for (const step of [windowsVet, macVet]) {
|
||||
const result = await vet(step, untrusted)
|
||||
expect(result.code).not.toBe(0)
|
||||
expect(result.stdout).toContain('not reachable from any branch or tag')
|
||||
}
|
||||
const result = await vet(macVet, 'refs/pull/1/head')
|
||||
expect(result.code).not.toBe(0)
|
||||
expect(result.stdout).toContain('Refusing to build PR ref')
|
||||
})
|
||||
|
||||
it('preserves both case variants in the subsequent full-history checkout', async () => {
|
||||
const checkout = join(directory, 'checkout')
|
||||
const env = { ...identity, ...macCheckout.env }
|
||||
await git(['init', checkout], env)
|
||||
await git(
|
||||
[
|
||||
'-C',
|
||||
checkout,
|
||||
'fetch',
|
||||
'--no-tags',
|
||||
repository,
|
||||
'+refs/heads/*:refs/remotes/origin/*',
|
||||
'+refs/tags/*:refs/tags/*'
|
||||
],
|
||||
env
|
||||
)
|
||||
await git(['-C', checkout, 'checkout', '--detach', upper], env)
|
||||
for (const [ref, sha] of [
|
||||
['refs/remotes/origin/Fix', upper],
|
||||
['refs/remotes/origin/fix', lower],
|
||||
['refs/tags/Release', upper],
|
||||
['refs/tags/release', lower]
|
||||
]) {
|
||||
expect(await git(['-C', checkout, 'rev-parse', `${ref}^{commit}`], env)).toBe(sha)
|
||||
}
|
||||
expect(await git(['-C', checkout, 'rev-parse', 'HEAD'], env)).toBe(upper)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/posix-hook-command.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/agent-hooks/windows-direct-cmd-hook-command.ts",
|
||||
"../src/main/agent-hooks/windows-powershell-hook-launcher.ts",
|
||||
"../src/main/amp/agent-status-plugin-source.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
@@ -117,6 +118,7 @@
|
||||
"../src/main/hermes/hermes-home-filesystem.ts",
|
||||
"../src/main/hermes/hermes-managed-plugin-source.ts",
|
||||
"../src/main/hermes/hook-service.ts",
|
||||
"../src/main/git-bash.ts",
|
||||
"../src/main/in-flight-run-dedupe.ts",
|
||||
"../src/main/kimi/hook-service.ts",
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import baseConfig from './vitest.config'
|
||||
|
||||
const contracts = [
|
||||
'src/main/sqlite/sync-database.test.ts',
|
||||
'src/main/runtime/orchestration/db/row-column-lists.test.ts',
|
||||
'src/relay/fs-path-metadata-symlink-concurrency.test.ts',
|
||||
'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts',
|
||||
'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',
|
||||
'config/scripts/app-store-performance-plugin.test.mjs',
|
||||
'config/scripts/quadratic-buffer-concat-plugin.test.mjs',
|
||||
'config/scripts/sort-comparator-performance-plugin.test.mjs'
|
||||
]
|
||||
|
||||
for (const contract of contracts) {
|
||||
if (!existsSync(resolve(contract))) {
|
||||
throw new Error(`Missing performance contract: ${contract}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
test: {
|
||||
...baseConfig.test,
|
||||
include: contracts,
|
||||
fileParallelism: false,
|
||||
retry: 0
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 40m">
|
||||
<title>downloads: 40m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 41m">
|
||||
<title>downloads: 41m</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">40m</text>
|
||||
<text x="90" y="14">40m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">41m</text>
|
||||
<text x="90" y="14">41m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -0,0 +1,199 @@
|
||||
# CI efficiency and runner capacity
|
||||
|
||||
Audit date: September 5, 2026. No paid capacity or provider configuration changed.
|
||||
|
||||
## Measurements and changes
|
||||
|
||||
Three recent successful PR runs used 54.6–64.9 aggregate runner minutes:
|
||||
[33998366568](https://github.com/stablyai/orca/actions/runs/33998366568),
|
||||
[33998220287](https://github.com/stablyai/orca/actions/runs/33998220287), and
|
||||
[33998181502](https://github.com/stablyai/orca/actions/runs/33998181502).
|
||||
These are sums of active job durations, excluding skipped jobs; they are not
|
||||
billing minutes or queue time. This small sample is not a historical average.
|
||||
|
||||
- Consolidate E2E routing into the existing code-path detector. The removed
|
||||
detector occupied 20–22 seconds and required another runner allocation and
|
||||
full-history checkout per nondraft code PR. The same routing commands remain,
|
||||
including SSH and native IME selection; actual E2E results remain advisory.
|
||||
A routing-script error now fails the required code-path detector.
|
||||
- Use gzip for PR-only Debian/RPM artifacts. The two sampled Linux packaging
|
||||
jobs took 8m10s and 8m19s overall; one spent 3m47s in electron-builder. Its
|
||||
default Debian/RPM compression is xz. PR artifacts are inspected on the same
|
||||
runner, so their download size offers no benefit. Keep all AppImage, Debian,
|
||||
RPM, payload, launcher, and shutdown checks. Release compression is unchanged.
|
||||
Hosted validation in [33999422341](https://github.com/stablyai/orca/actions/runs/33999422341)
|
||||
reduced the package-build step to 2m13s and the full Linux job to 6m17s, with
|
||||
all existing checks passing. This is a small observational sample.
|
||||
- Cancel superseded Mobile Checks and Skill update round-trip PR runs. The
|
||||
skill matrix has 13 jobs. Preserve non-cancelling main/merge-group skill runs,
|
||||
with separate concurrency groups per event.
|
||||
- Reuse the existing script-free root dependency action in Mobile Checks,
|
||||
including the pnpm cache keyed by both root and mobile lockfiles. The root
|
||||
install remains necessary because mobile types import root dependencies.
|
||||
|
||||
The repository already has eight unit shards, path-scoped platform checks,
|
||||
native caches, one shared E2E build, PR cancellation, incremental TypeScript
|
||||
caching, and changed-spec E2E routing. Increasing shards would increase setup
|
||||
work and simultaneous runner demand. Do not adjust the count without comparing
|
||||
critical-path time and aggregate job time on the same commit.
|
||||
|
||||
## Follow-up savings
|
||||
|
||||
- Move the hourly main/release freshness lookup to a five-minute Ubuntu
|
||||
preflight without a checkout. In unchanged run
|
||||
[33986205749](https://github.com/stablyai/orca/actions/runs/33986205749),
|
||||
Blacksmith macOS was occupied for 40 seconds, including a 30-second checkout,
|
||||
before skipping. The new job-level gate avoids that Mac allocation. Actual
|
||||
builds gain an Ubuntu scheduling hop; pin the Mac checkout and downstream
|
||||
Windows identity to the SHA that the preflight checked.
|
||||
- Avoid global `npm install -g node-gyp` for validated Linux Node-runtime cache
|
||||
hits. Use the existing native-module load/provenance check before skipping;
|
||||
misses, broken addons, and Electron jobs still install the rebuild toolchain.
|
||||
The action file participates in cache keys, so this rollout creates fresh
|
||||
native caches once. No measured warm-cache seconds are claimed yet.
|
||||
|
||||
## Runner recommendations
|
||||
|
||||
The repository is **public**, verified using the GitHub API. Standard
|
||||
GitHub-hosted Linux, Windows, and macOS runners have free compute minutes for
|
||||
public repositories. Queue pressure and third-party provider allowances still
|
||||
matter; artifact storage and larger runners have separate billing rules.
|
||||
See [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions).
|
||||
|
||||
1. Keep standard GitHub-hosted runners as the default. Ask GitHub Support for a
|
||||
higher concurrent-job limit before paying for more capacity. The documented
|
||||
standard limits depend on the account plan (Free: 20 total/5 macOS; Team:
|
||||
60/5; Enterprise: 500/50), and increases are subject to approval. The actual
|
||||
account entitlement was not verified. See [limits](https://docs.github.com/en/actions/reference/limits).
|
||||
2. Reserve existing Blacksmith allowance for macOS if that is the priority.
|
||||
Blacksmith documents 3,000 free x64 2-vCPU-equivalent minutes per organization;
|
||||
a 6-vCPU Mac minute consumes 20 equivalents, or 150 actual Mac minutes if
|
||||
it uses the entire free pool. Cloud workflows also use Blacksmith Linux.
|
||||
Moving Linux to hosted GitHub saves shared allowance, but does not necessarily
|
||||
free Mac hardware capacity. Account-specific contracts and usage were not
|
||||
inspected. See [Blacksmith runners](https://docs.blacksmith.sh/blacksmith-runners/overview).
|
||||
3. Treat Ubicloud as an optional small Linux overflow trial. Its documented
|
||||
$2.50 monthly credit buys 1,250 premium 2-vCPU minutes at $0.002/minute, or
|
||||
2,000 standard 2-vCPU minutes at $0.00125/minute. New accounts default to
|
||||
premium and require a credit card. No enforceable hard spending cap was
|
||||
verified, so changing runner labels cannot guarantee the no-spend constraint.
|
||||
One PR's roughly 55–65 runner minutes also makes clear how small this pool
|
||||
is relative to repository activity (hardware speeds differ).
|
||||
See [pricing](https://ubicloud.com/docs/about/pricing) and
|
||||
[setup](https://ubicloud.com/docs/github-actions-integration/quickstart).
|
||||
|
||||
### A bounded Ubicloud candidate
|
||||
|
||||
The Linux leg of `performance-contracts.yml` took 48 seconds in
|
||||
[33994756657](https://github.com/stablyai/orca/actions/runs/33994756657).
|
||||
Its daily schedule and 20-minute timeout make it a small candidate: 31 ordinary
|
||||
scheduled attempts permit at most 620 job-runtime minutes, before runner
|
||||
startup/cleanup billing. Actual timings on Ubicloud's 2-vCPU hardware still need
|
||||
measurement; the GitHub timing is only a sizing reference.
|
||||
|
||||
If enabled later, route only the first attempt of the scheduled Linux job to
|
||||
Ubicloud; keep PRs, manual dispatches, reruns, and macOS/Windows on GitHub. This
|
||||
avoids spending the allowance on unpredictable PR volume. Check other account
|
||||
usage and available credit before enabling; a workflow timeout is not an
|
||||
account-wide billing cap. On September 5, the organization's GitHub App
|
||||
installation list contained Blacksmith but no Ubicloud installation, so this
|
||||
follow-up leaves runner selection on GitHub rather than queueing work against
|
||||
an unprovisioned label.
|
||||
|
||||
## Machines that also run coding agents
|
||||
|
||||
Do not register the credentialed host directly as a public-PR runner. A PR can
|
||||
execute arbitrary build/test code, and a persistent host lets it access local
|
||||
credentials or affect subsequent jobs. Docker alone is not adequate isolation
|
||||
when it exposes the host home, Docker socket, SSH agent, or office network.
|
||||
|
||||
A possible no-new-hardware experiment is a disposable VM per job, preferably on
|
||||
a dedicated spare machine, with a just-in-time single-job runner, no shared
|
||||
home/keychain/SSH agent or host mounts, restricted network access, and CPU/RAM
|
||||
limits that leave room for coding agents. Destroy the VM after every job;
|
||||
ephemeral runner registration by itself does not clean the machine. Start with
|
||||
trusted branch/manual workloads and keep public fork PRs on hosted runners.
|
||||
Provisioning and ongoing patching are real operational costs even when the
|
||||
machine is already owned. See GitHub's
|
||||
[self-hosted runner security guidance](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions).
|
||||
|
||||
## Release waits
|
||||
|
||||
The latest successful sampled Windows release used 13m59s of a 21m56s job in
|
||||
signing wait/download steps. The same release held an Ubuntu job for 11m38s
|
||||
polling the isolated Mac build. These are stronger occupancy opportunities than
|
||||
small checkout savings, especially when approval takes hours.
|
||||
|
||||
[Windows signing without occupying a runner](windows-signing-runner-time.md)
|
||||
describes a staged, same-run design, required protected environments, and
|
||||
rehearsal criteria. No callback integration or protected Windows signing
|
||||
environments currently exist. An environment-gated design adds a GitHub
|
||||
approval after each SignPath approval and changes the current automatic inner
|
||||
signing timeout fallback; those are explicit release-policy decisions, so this
|
||||
PR leaves production signing behavior unchanged.
|
||||
|
||||
## Second audit and hosted trials
|
||||
|
||||
- Cloud Verify ran 100 times in a sampled 39-hour window (84 PR and 16 push
|
||||
runs). Move its four Ubuntu 22.04 jobs from Blacksmith to standard hosted
|
||||
Ubuntu 22.04, preserving Postgres, secret scanning, build, tests, and Terraform
|
||||
validation. Baseline [34001538145](https://github.com/stablyai/orca/actions/runs/34001538145)
|
||||
used 64/72/26/19 seconds for security/test/build/Terraform respectively.
|
||||
This conserves the shared provider allowance; hosted latency must be checked.
|
||||
- Keep full tag history for the 13-job skill round-trip matrix, but fetch blobs
|
||||
lazily. Only two historical SKILL.md files are materialized. Baseline
|
||||
[33999994876](https://github.com/stablyai/orca/actions/runs/33999994876)
|
||||
spent 42–84 seconds per checkout, about 14 aggregate runner minutes. A hosted
|
||||
trial must verify historical blob fetches on all three operating systems.
|
||||
- Use the existing Electron/native dependency cache for native IME CI. Keep
|
||||
both deterministic boundary and real IBus tests. Add pnpm store caching to
|
||||
terminal perf and release golden/evidence lanes; retain their raw installs
|
||||
because manually selected older refs may not contain the shared action.
|
||||
- Disable ZIP recompression only for already-compressed NSIS installers sent
|
||||
to SignPath. Installer contents, release compression, and signing stay intact.
|
||||
- Advance existing placement and startup deadlines with scoped fake timers in
|
||||
three renderer test files. All 34 tests pass in 62 ms of local test execution,
|
||||
versus 65.182 seconds in the sampled hosted baseline. Imports and transforms
|
||||
still dominate invocation time; this is not a claim of equal PR wall savings.
|
||||
|
||||
Eight unit shards already have balanced 260–296-second sample durations.
|
||||
Reducing shards or removing test isolation lacks evidence of a net gain. Real
|
||||
subprocess tests intentionally cover lifecycle behavior and retain real clocks.
|
||||
The 14-way E2E split retains headroom after earlier 12-way timeouts. Lowering
|
||||
coverage or schedule frequency is outside this efficiency pass. Cache complexity
|
||||
for a seven-second docs install is unlikely to pay back. Release build reuse
|
||||
across modes risks differing telemetry identities and native platform artifacts.
|
||||
|
||||
Terminal Perf's baseline [33955846492](https://github.com/stablyai/orca/actions/runs/33955846492)
|
||||
failed waiting 30 seconds for workspaceSessionReady in its shared-page fixture,
|
||||
before measuring terminal performance. Compare hosted trials against that known
|
||||
failure rather than attributing it to dependency cache changes.
|
||||
|
||||
Hosted trials for the second audit:
|
||||
|
||||
- [Cloud Verify 34002295216](https://github.com/stablyai/orca/actions/runs/34002295216)
|
||||
passed all four jobs on standard hosted Ubuntu: security 57s, test 102s, build
|
||||
35s, Terraform 19s. The test lane is 30s slower than the Blacksmith sample;
|
||||
retain this modest latency tradeoff to conserve shared allowance.
|
||||
- [Skill matrix 34002295221](https://github.com/stablyai/orca/actions/runs/34002295221)
|
||||
passed all 13 legs, including historical blob materialization. Checkout took
|
||||
18–20s on Linux, 39–45s on macOS, and 49–58s on Windows, versus the earlier
|
||||
42–84s range across platforms. These are observational samples.
|
||||
- [Native IME 34002299594](https://github.com/stablyai/orca/actions/runs/34002299594)
|
||||
passed both deterministic and real IBus checks. Shared dependency setup took
|
||||
29s, versus 35s for the old install/toolchain steps in the sampled baseline.
|
||||
- Native-IME-only source/spec changes no longer allocate the reusable E2E
|
||||
build, cache, and consumer jobs just to filter out the native spec. The
|
||||
separate native workflow still runs; SSH-only and mixed spec lists still
|
||||
allocate the reusable workflow. Routing contracts exercise these cases.
|
||||
- [Hourly 34001816449](https://github.com/stablyai/orca/actions/runs/34001816449)
|
||||
exercised the new five-second preflight and successfully published macOS.
|
||||
The Windows follow-up failed in its unchanged input-vetting fetch because
|
||||
remote refs differ only by case on its case-insensitive filesystem. The
|
||||
requested SHA was correct; this does not validate an unchanged-main skip yet.
|
||||
|
||||
Moving the daily Mac freshness check has lower expected value than hourly:
|
||||
only one potential idle allocation per day, and active development usually
|
||||
requires that build. Defer another release-graph change until skip frequency
|
||||
justifies it. The substantive remaining release occupancy opportunity is the
|
||||
separately documented asynchronous signing policy decision.
|
||||
@@ -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
|
||||
|
||||
@@ -166,7 +191,8 @@ What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
|
||||
`src/main/providers/windows-shell-args.ts`), the hook wrappers
|
||||
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
|
||||
`src/main/agent-hooks/runtime-home-hook-command.ts`,
|
||||
`src/main/agent-hooks/installer-utils.ts`, `src/main/claude/hook-settings.ts`),
|
||||
`src/main/agent-hooks/installer-utils.ts`, and `src/main/claude/hook-settings.ts`
|
||||
— that last one only as a *fallback* since #18875, see below),
|
||||
`src/main/runtime/windows-default-route-interfaces.ts`,
|
||||
`src/main/runtime/orchestration/setup-completion-signal.ts`,
|
||||
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
|
||||
@@ -231,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
|
||||
@@ -242,6 +269,41 @@ breadth: every interpreter hop between Orca and the thing the user asked for add
|
||||
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
|
||||
*shorten the interpreter chain* rather than to hide a window.
|
||||
|
||||
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
|
||||
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
|
||||
decoded payload was a `Test-Path` and a call to `~/.orca/agent-hooks/claude-hook.cmd`.
|
||||
It now registers the script path itself (`<path> || echo {}`), so `bash ->
|
||||
powershell -> cmd -> curl` became `bash -> cmd -> curl` and one
|
||||
`powershell.exe -EncodedCommand` per hook event — a first-class Defender alert
|
||||
title — leaves the tree. The reporting box fired ~6 900 of them in five days,
|
||||
70% from Claude sessions that were not running under Orca at all and whose hook
|
||||
exits at its first `ORCA_PANE_KEY` guard.
|
||||
|
||||
What is measured is latency and the hop count, nothing else: median 471 ms ->
|
||||
213 ms per event idle, and 656 ms -> 296 ms (p95 696 ms -> 337 ms) under 10-way
|
||||
concurrency, invoked as Claude Code invokes it. **No EDR verdict on either tree
|
||||
was measured**, so claim the removed `-EncodedCommand` spelling and the shorter
|
||||
chain, not a score. `cmd.exe` remains in the tree, spelled by MSYS's own `.cmd`
|
||||
spawn rather than by us — the doc's one "unavoidable for `.cmd`/`.bat`" case,
|
||||
carrying an absolute path and two literal tokens, with no caret escaping, no
|
||||
encoding and no free text. The encoded launcher is still the shape for profile
|
||||
paths the shells cannot carry bare (a space, `%`, `^`, `&`, non-ASCII, a UNC
|
||||
profile) and for hosts where Git Bash is not resolvable, because PowerShell 5.1
|
||||
rejects `||` (measured: parse error, exit 1).
|
||||
|
||||
That last clause is the standing assumption of this change, and it is worth
|
||||
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
|
||||
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
|
||||
any host that is one of the first three. Claude Code itself is a Git Bash host on
|
||||
native Windows. What no one here has verified is which host a *compat consumer*
|
||||
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
|
||||
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
|
||||
for exactly that). If one of them spawns hook strings through Windows PowerShell
|
||||
5.1, its imported Claude events become a parse error with empty stdout, which is
|
||||
the fail-closed case #14818 exists to prevent. The encoded launcher had no such
|
||||
assumption — it was a `powershell.exe` invocation and therefore parsed anywhere.
|
||||
Before widening the direct shape to another agent, measure that consumer's host.
|
||||
|
||||
### Computer use: screen capture, synthetic input, runtime-compiled MSIL
|
||||
|
||||
`native/computer-use-windows/runtime.ps1` is a large PowerShell script.
|
||||
@@ -327,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:
|
||||
|
||||
@@ -16,38 +16,193 @@ table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
|
||||
|
||||
```ts
|
||||
import {
|
||||
readWindowsProcessIdentityTable,
|
||||
readWindowsProcessIdentityTableFresh,
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh
|
||||
} from '../windows/windows-process-table'
|
||||
```
|
||||
|
||||
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
|
||||
- `readWindowsProcessTableFresh()` — a snapshot that starts after the call. Use
|
||||
for teardown identity, where a cached row can predate the exit it is being
|
||||
asked about.
|
||||
Each pair is a shared TTL cache plus a `Fresh` variant that starts its scan
|
||||
after the call. Use `Fresh` for teardown identity, where a cached row can
|
||||
predate the exit it is being asked about, and the cached one for anything
|
||||
periodic.
|
||||
|
||||
Both **reject** when the table cannot be read. Do not convert that into an empty
|
||||
array. An empty table is a claim that nothing is running, and callers act on
|
||||
that claim by declaring a tree dead or a shell childless. "Unavailable" has to
|
||||
stay distinguishable from "empty" — collapsing the two is how a PTY tree
|
||||
All four **reject** when the table cannot be read. Do not convert that into an
|
||||
empty array. An empty table is a claim that nothing is running, and callers act
|
||||
on that claim by declaring a tree dead or a shell childless. "Unavailable" has
|
||||
to stay distinguishable from "empty" — collapsing the two is how a PTY tree
|
||||
survived its own teardown (#9045).
|
||||
|
||||
Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
## Two flag sets: ask for a command line only if you read one
|
||||
|
||||
Neither flag is a wider column on the same query. Each is a separate
|
||||
per-process syscall sequence, and they are not equally expensive to the EDR
|
||||
watching:
|
||||
|
||||
- `CommandLine` (`process_commandline.cc`) —
|
||||
`OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, then
|
||||
`NtQueryInformationProcess(ProcessCommandLineInformation)` twice: once to size
|
||||
the buffer, once to fill it. The kernel builds the string, so no address space
|
||||
is opened or read. It used to walk the target's PEB with three chained
|
||||
`ReadProcessMemory` calls; the patched addon no longer contains that primitive.
|
||||
- `Memory` (`process.cc`) — retired. It took a **second** `OpenProcess`, and that
|
||||
one carried `PROCESS_VM_READ`, which it acquired and never used.
|
||||
|
||||
Measured here (541 processes, 405 openable), per detailed scan, before → after
|
||||
dropping `Memory`: `OpenProcess` 1082 → 541. That halving is all the `Memory`
|
||||
drop bought on its own — both handles carried `PROCESS_VM_READ` at the time, so
|
||||
it moved the PEB traffic not at all. Replacing the PEB walk with the kernel
|
||||
query is what took `PROCESS_VM_READ` and `ReadProcessMemory` out of the addon
|
||||
altogether; the two changes compose, and neither substitutes for the other.
|
||||
|
||||
So be precise about what these two flag sets buy now. A detailed scan is one
|
||||
`OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process and no memory
|
||||
access at all. What the split buys on top of that is the handle itself: an
|
||||
identity scan opens nothing.
|
||||
|
||||
So the module exposes two snapshots, and the row types differ so a cheap caller
|
||||
cannot read what its flag set did not pay for:
|
||||
|
||||
| reader | row type | flags | per-process handles |
|
||||
| ------------------------------------------ | ---------------------------- | --------------------------- | ------------------- |
|
||||
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
|
||||
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
|
||||
|
||||
`Memory` is requested by neither. Nothing reads a working set off this table —
|
||||
`windows-process-resource-collector.ts` runs its own sweep because it needs
|
||||
commit and CPU counters in the same pass, and the addon stores `WorkingSetSize`
|
||||
into a `DWORD` so anything above 4 GB wraps anyway.
|
||||
|
||||
Measured on Windows 11 with 492 processes (p50 / p95):
|
||||
|
||||
| | p50 | p95 |
|
||||
| -------------------------------- | ------- | ------- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| identity (pid + ppid + name) | 6.3 ms | 7.0 ms |
|
||||
| detailed (+ command line) | 12.3 ms | 13.4 ms |
|
||||
| _retired_ (+ memory) | 13.1 ms | 14.1 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those are the module's published figures. The flag set this module actually
|
||||
requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second
|
||||
`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus
|
||||
`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing
|
||||
read. Dropping it halves the handles a snapshot opens. The remaining set sits
|
||||
between the two rows above and has not been measured separately; on a real
|
||||
Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a
|
||||
snapshot cadence is the check.
|
||||
There are exactly **two** caches, never one per caller. The fan-out this module
|
||||
exists to prevent 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. A third cache would need a third flag set, not a third caller.
|
||||
|
||||
### Only one native read may be in flight, ever
|
||||
|
||||
This is the price of having two flag sets, and it is not optional.
|
||||
|
||||
The npm wrapper **coalesces rather than queues**. `getRawProcessList` pushes the
|
||||
callback onto one list and calls the addon only when no request is in progress,
|
||||
so a second concurrent caller's `flags` are **discarded** and it is handed the
|
||||
first caller's rows. Measured against the real addon: issue identity first, both
|
||||
callers get the same array, 0 of 541 rows carry a command line. A detailed read
|
||||
that overlaps an identity read therefore returns a table with **every command
|
||||
line empty**, and agent recognition reads that as "no agent" — silently, and
|
||||
only under concurrency.
|
||||
|
||||
Nothing else in this module prevents that. Each snapshot cache single-flights
|
||||
only within itself (`inFlight` is a closure per reader), and the wedge set
|
||||
latches only *after* a read misses its 3 s deadline, so through the healthy
|
||||
~12 ms of a scan neither excludes the other. Overlap is the normal state rather
|
||||
than an edge case: other panes keep polling detailed at 750 ms while a teardown
|
||||
takes identity snapshots, and `codex-structured-turn-processes.ts` issues fresh
|
||||
detailed scans on turn stop.
|
||||
|
||||
`nativeReadGate` serializes every native read across both flag sets. It is also
|
||||
what makes the relay's bare addon safe: `adaptAddon` has no queue at all, and
|
||||
two simultaneous `CreateToolhelp32Snapshot` calls are the crash the vendor's
|
||||
queue exists to prevent. Every link settles — a wedged read still rejects on its
|
||||
deadline — so a waiter is never stranded; it re-checks the wedge and rejects.
|
||||
|
||||
Because only one native call is ever outstanding, the wedge gate and the 3 s
|
||||
deadline stay **shared** and retention stays bounded at exactly one callback,
|
||||
not one per reader. Read ids are module-global and monotonic, so a late callback
|
||||
can only clear its own wedge.
|
||||
|
||||
`resetNativeReaderState` **chains** onto the gate rather than replacing it. A
|
||||
replacement would let a waiter still holding the old chain run beside a read
|
||||
queued on the new one; every link settles within the deadline, so chaining costs
|
||||
a bounded wait and keeps the exclusion whole. That path is test-only, which is
|
||||
exactly why it matters — it would otherwise hand a suite two concurrent calls
|
||||
into its own mock, the condition these tests exist to detect.
|
||||
|
||||
### Testing this module: assert a positive property, on the right mock
|
||||
|
||||
Three defects have now shipped in this file's tests, all the same shape — a case
|
||||
that passed for a reason other than the one it claimed to check:
|
||||
|
||||
1. A loader that built a **fresh mock per call**, so the coalescing it was meant
|
||||
to reproduce could never happen.
|
||||
2. An identity-side assertion of only `!('command' in row)`, which a correctly
|
||||
flagged read and a coalesced one satisfy equally, so the test would go green
|
||||
on the very regression it guards.
|
||||
3. A concurrency assertion placed on the **coalescing** mock, whose own
|
||||
`requestInProgress` latch means it can never report more than one call in
|
||||
flight — so it held whether or not this module excluded anything, and passed
|
||||
against a read gate that had genuinely lost exclusion.
|
||||
|
||||
The third arrived in the fix for the first two, which is the point: this is not a
|
||||
mistake you make once.
|
||||
|
||||
So: assert what each flag set **did** get, not only what it lacks, and put those
|
||||
assertions in the helper both orderings run through, or the reverse order keeps
|
||||
the blind spot. The identity set is checked on `creationTimeMs` because that is
|
||||
the field it exists to carry. Keep both that check and the flags-array check —
|
||||
they catch **different** failures and neither is redundant. The flags array
|
||||
catches a read served another flag set's rows (the coalescing bug); the
|
||||
positional `creationTimeMs` check catches field shaping — identity dropping
|
||||
`CreationTime` from its flags, or `toIdentityRow` failing to forward it — which
|
||||
no flags assertion would notice.
|
||||
|
||||
And pick the mock to match the claim. The coalescing mock models the npm
|
||||
wrapper's queue semantics and is the only place to assert those. Concurrency has
|
||||
to be measured against the bare-addon mock, which has no queue and so makes
|
||||
re-entry observable.
|
||||
|
||||
With no native binding there is only one scan to run and it is the 1.4 s
|
||||
PowerShell one, so the identity view rides the detailed snapshot — projected
|
||||
through `toIdentityRow`, so an identity row carries no command line on any host.
|
||||
|
||||
### Which callers need which
|
||||
|
||||
| caller | reads | flag set |
|
||||
| --------------------------------------------- | ------------------ | -------- |
|
||||
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
|
||||
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
|
||||
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
|
||||
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
|
||||
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
|
||||
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
|
||||
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
|
||||
|
||||
`windows-port-scan.ts` is the one mismatch in the table: it reads only `pid` and
|
||||
`name`, which the identity set answers, but it calls the detailed reader. On a
|
||||
host with a live pane that costs nothing extra — the detailed snapshot is
|
||||
already cached — and on a headless relay it pays for a command line no caller
|
||||
reads. Left as-is deliberately, because moving it to identity would trade that
|
||||
for a second scan whenever a pane is polling; revisit if the relay ever scans
|
||||
ports without one.
|
||||
|
||||
The per-pane foreground tracker is the hot one (750 ms / 2 s cadence) and it
|
||||
genuinely needs the command line, so the repeating per-process `OpenProcess` is
|
||||
not something the split removes. What the split removes is that handle from
|
||||
teardown identity and from the owner probe, which now open nothing.
|
||||
|
||||
### `creationTimeMs` does not exist on any shipped build
|
||||
|
||||
Nothing in the repo supplies a `CreationTime` flag. The package enum is
|
||||
`None`/`Memory`/`CommandLine`, `process_worker.cc` emits no `creationTimeMs`,
|
||||
the vendored patch adds none, and `adaptAddon`'s `PROCESS_DATA_FLAG` lacks the
|
||||
bit. So `creationTimeMs` is always `undefined` in production and
|
||||
`isWindowsProcessStartTimeAvailable()` is always `false` — a latent product gap
|
||||
that predates the split and needs its own owner.
|
||||
|
||||
Two consequences. `IDENTITY_PROJECTION.flags` evaluates to `0` today, so the
|
||||
identity reader really does open zero handles. And
|
||||
`agent-session-process-identity-probe.ts` early-returns on
|
||||
`isWindowsProcessStartTimeAvailable()` rather than scanning the whole table to
|
||||
produce `null`. Do not build anything on Windows start time working.
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
@@ -189,7 +344,7 @@ on any other OS keeps using the scan.
|
||||
|
||||
## Why the package is patched
|
||||
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks.
|
||||
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries four hunks.
|
||||
|
||||
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
|
||||
libraries, which Orca's Windows build agents do not install. `node-pty` is
|
||||
@@ -204,10 +359,122 @@ on any other OS keeps using the scan.
|
||||
realpath, then loads the relative path from the `node_modules` symlink, so
|
||||
`node_addon_api.gyp` resolves outside the repo and hourly Windows builds
|
||||
die at configure. `node-pty` is patched the same way for the same reason.
|
||||
4. **No PEB reads, no `PROCESS_VM_READ`.** See below.
|
||||
|
||||
The typings claim `commandLine` is truncated at 512 characters. Measured, it is
|
||||
not: the longest observed on a real host was 26,059.
|
||||
|
||||
### The command line comes from the kernel, not the target's memory
|
||||
|
||||
Upstream, `GetProcessCommandLine` opens every process with
|
||||
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and issues three chained
|
||||
`ReadProcessMemory` calls — PEB, `RTL_USER_PROCESS_PARAMETERS`, then the string
|
||||
— to recover the command line. Walking another process's address space for
|
||||
credentials-adjacent data on a repeating timer is what a credential dumper does,
|
||||
so Defender for Endpoint scores it as such regardless of intent. Nothing about
|
||||
the flag sets above changes that; only removing the read does.
|
||||
|
||||
Windows 8.1 added `NtQueryInformationProcess`'s `ProcessCommandLineInformation`
|
||||
class (60), which returns the same string as a `UNICODE_STRING` the kernel
|
||||
builds, needing only `PROCESS_QUERY_LIMITED_INFORMATION`. Electron's floor is
|
||||
Windows 10, so every OS Orca supports has it. The entry point is resolved with
|
||||
`GetProcAddress` on `ntdll.dll` — it has no import library — and the size is
|
||||
probed with a null-buffer call that answers `STATUS_INFO_LENGTH_MISMATCH`.
|
||||
|
||||
The same hunk drops `PROCESS_VM_READ` from `GetProcessMemoryUsage` and
|
||||
`GetCpuUsage`, which acquired it and never read an address space:
|
||||
`GetProcessMemoryInfo` and `GetProcessTimes` are satisfied by
|
||||
`PROCESS_QUERY_LIMITED_INFORMATION`. Measured, both return identical values
|
||||
under the weaker right on every process that opens at all.
|
||||
|
||||
Measured on Windows 11, ~540 processes, counted in-process by replacing the
|
||||
addon's import table entries with counting stubs:
|
||||
|
||||
| per `CommandLine` scan | before | after |
|
||||
| ---------------------- | ----------------------------------------- | -------------------------------------- |
|
||||
| `OpenProcess` calls | 543 | 543 |
|
||||
| desired access | `0x0410` (`VM_READ \| QUERY_INFORMATION`) | `0x1000` (`QUERY_LIMITED_INFORMATION`) |
|
||||
| `ReadProcessMemory` | 1128 | **0** |
|
||||
| p50 / p95 | 13.5 / 14.5 ms | 12.3 / 13.5 ms |
|
||||
|
||||
Command lines were byte-identical on every process both readers recovered
|
||||
(405/405, and 399/399 and 376/376 on other runs), including a 24,087-character
|
||||
argv with embedded quotes, non-ASCII characters and trailing whitespace, and a
|
||||
WOW64 target. The weaker right is also a strict superset in reach: three
|
||||
processes that refused `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` granted
|
||||
`PROCESS_QUERY_LIMITED_INFORMATION`, and none went the other way.
|
||||
|
||||
### There is no PEB fallback, deliberately
|
||||
|
||||
An earlier revision kept the PEB reader for a kernel without class 60, behind a
|
||||
latch. That was wrong, and the reason is worth recording: `ClassifyQueryFailure`
|
||||
mapped `STATUS_INVALID_INFO_CLASS` / `NOT_SUPPORTED` / `NOT_IMPLEMENTED` from
|
||||
**any single target** onto a process-wide, one-way switch back to
|
||||
`PROCESS_VM_READ` plus three `ReadProcessMemory` per pid per scan, for the life
|
||||
of the process, with nothing observable from JS.
|
||||
|
||||
The environment this reader exists for is one where an EDR hooks `ntdll`. A hook
|
||||
that returns `STATUS_INVALID_INFO_CLASS` for a class it does not recognise would
|
||||
have silently reinstated the exact primitive the patch removes, on precisely the
|
||||
machines it was written for — and one stray status from one process was enough.
|
||||
The same applies under Wine or any instrumented `ntdll`.
|
||||
|
||||
So the fallback is gone rather than guarded. `GetProcessCommandLine` returns
|
||||
false and leaves the command line empty, which is already a normal outcome
|
||||
(`WindowsProcessRow.command` is documented as empty when a process denies a
|
||||
query handle, and callers fall back to the image name). Degrading to no command
|
||||
line is recoverable; silently resuming address-space reads is not.
|
||||
|
||||
This also makes the property checkable on the artifact rather than the source:
|
||||
the patched reader never calls `ReadProcessMemory`, so the symbol is absent from
|
||||
the compiled addon's import table. `inspectWindowsProcessTreeAddon()` in
|
||||
`config/scripts/windows-process-tree-gyp-rebuild.mjs` is that check, and it is
|
||||
the only way to tell the two binaries apart — see below. It answers
|
||||
`clean` / `unpatched` / `missing` rather than a boolean, because a binary that is
|
||||
not there has not been cleared, and a caller reading `false` as “verified” would
|
||||
pass exactly the thing the check exists to catch.
|
||||
|
||||
Because the returned `UNICODE_STRING` comes from that same hookable boundary,
|
||||
its `Buffer` and `Length` are bounds-checked against the allocation before the
|
||||
characters are encoded, and the probed size is capped at the header plus 64 KiB
|
||||
(`Length` is a `USHORT`) so a bogus size cannot turn into a `bad_alloc` that
|
||||
fails an entire scan instead of one process.
|
||||
|
||||
### The published tarball ships a loadable unpatched prebuilt
|
||||
|
||||
`@vscode/windows-process-tree@0.8.0` publishes
|
||||
`build/Release/windows_process_tree.node` in the tarball. It is node-addon-api,
|
||||
so it is ABI-stable and loads cleanly under both Node and Electron — and it was
|
||||
built from unpatched source, so it performs 1179 `ReadProcessMemory` calls and
|
||||
opens every process at `0x0410` per scan.
|
||||
|
||||
That matters because `allowBuilds` is `false` for this package and CI installs
|
||||
with `--ignore-scripts`, so nothing compiles it at install time. A `require()`
|
||||
health check cannot tell the two binaries apart, and a rebuild that is skipped —
|
||||
`rebuild-native-deps.mjs` soft-exits 0 on a Windows file lock during postinstall
|
||||
— leaves the upstream prebuilt in place and cached.
|
||||
|
||||
Four checks close that, all keyed on the absent `ReadProcessMemory` import:
|
||||
|
||||
- `ensureWindowsProcessTreeCommandLinePatch()` deletes a binary that still has
|
||||
it, so a skipped rebuild fails loudly instead of using the prebuilt;
|
||||
- `ensure-native-runtime.mjs` treats such a binary as a load failure, which is
|
||||
what triggers the rebuild;
|
||||
- the relay build asserts it on the artifact it just produced;
|
||||
- `loadWindowsProcessTree()` asserts it again on the addon staged beside a relay
|
||||
bundle and refuses to bind one that still imports the symbol, falling back to
|
||||
the CIM scan. The build-time assertion is not enough on its own: a bundle and
|
||||
the addon beside it redeploy independently, so a host that has not taken a new
|
||||
bundle keeps whatever `.node` is already there.
|
||||
|
||||
What none of this does is narrow _which_ processes are asked. A detailed scan
|
||||
still queries every pid, including `lsass.exe`; it now asks with the same right
|
||||
Task Manager uses instead of `PROCESS_VM_READ`. Restricting the command-line
|
||||
pass to Orca's own subtree is the complementary change, and it belongs with the
|
||||
identity/detailed reader split rather than here — a ppid-derived allowlist would
|
||||
miss exactly the detached, reparented descendants the trackers exist to find
|
||||
(#9045, #10475), so it needs the job-object membership as its source of truth.
|
||||
|
||||
## Packaging
|
||||
|
||||
The addon is Windows-only, so it follows the same contract as
|
||||
@@ -221,6 +488,10 @@ The addon is Windows-only, so it follows the same contract as
|
||||
`ensure-native-runtime.mjs`;
|
||||
- copied into the packaged `node_modules` for win32 only.
|
||||
|
||||
The relay's copy is a separate artifact staged beside the bundle, so a relay host
|
||||
only picks up a rebuilt addon on redeploy. Until then it keeps whatever binary it
|
||||
already has, which is why the addon is checked again at load.
|
||||
|
||||
## What the snapshot does not provide
|
||||
|
||||
`CreationDate` (process start time) has no equivalent. Anything using a start
|
||||
@@ -229,9 +500,10 @@ ownership, and CPU accounting in the memory collector — still reads it through
|
||||
its own query. Those callers are not migrated.
|
||||
|
||||
Committed private bytes have no equivalent either, and the one memory value the
|
||||
snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the
|
||||
second reason `windows-process-resource-collector.ts` still runs its own
|
||||
addon can produce is unusable for the sizes Orca now sees: `process.cc` stores
|
||||
`pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps — which is why
|
||||
neither flag set asks for it. That is the second reason
|
||||
`windows-process-resource-collector.ts` still runs its own
|
||||
`Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time
|
||||
counters in the same pass. Migrating it to the native table would cost both, and
|
||||
it is why this module no longer sets the `Memory` flag at all: the field had no
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Windows signing without occupying a runner during approval
|
||||
|
||||
Status: implementation proposal; production signing behavior is unchanged.
|
||||
|
||||
## Measured cost
|
||||
|
||||
In [release run 33821033674](https://github.com/stablyai/orca/actions/runs/33821033674)
|
||||
(September 4, 2026), the Windows job took 21m56s. The inner-binary download step
|
||||
took 13m19s and the installer download step took 40s: 13m59s, or 64% of the job,
|
||||
was spent in the signing download/wait steps. These durations include the
|
||||
download itself, so they are an upper bound on removable idle time, not a
|
||||
prediction of net savings after transferring state between jobs.
|
||||
|
||||
`release-cut.yml` submits both requests with `wait-for-completion: false`, but
|
||||
then invokes `Get-SignedArtifact` on the same Windows runner with one-hour and
|
||||
four-hour completion timeouts. The six-hour job timeout accommodates both
|
||||
waits. Changing the submission flag again, polling less often, or running the
|
||||
wait inside a container does not release the runner slot.
|
||||
|
||||
This is runner occupancy, not a billing estimate. Standard GitHub-hosted
|
||||
runners in a public repository may be free; removing the waits still releases
|
||||
concurrency for other work. Check actual billing before assigning dollar savings.
|
||||
|
||||
The same release also occupied an Ubuntu runner for 11m38s while
|
||||
`run-release-mac-build-workflow.mjs` waited on the isolated macOS workflow.
|
||||
That is a separate orchestration optimization. Windows development-channel
|
||||
builds deliberately ship unsigned and have no SignPath wait to remove.
|
||||
|
||||
## Proposed execution graph
|
||||
|
||||
Keep all Windows stages in the original `release-cut.yml` run to preserve the
|
||||
current SignPath GitHub artifact provenance boundary:
|
||||
|
||||
1. `build-windows` builds and uploads the unpacked app, original installer,
|
||||
updater metadata, and inner-signing manifest. It submits the inner request,
|
||||
sends the existing notification, exposes the request ID, and finishes.
|
||||
2. `package-windows` depends on that job and uses a protected environment named
|
||||
`windows-inner-signing`. Its runner is allocated only after GitHub approval.
|
||||
It restores the exact build, downloads the signed binaries with a short,
|
||||
bounded completion wait, applies the existing signature restoration and
|
||||
signed `elevate.exe` cache replacement, builds the NSIS installer, uploads it,
|
||||
submits the second signing request, notifies approvers, and finishes.
|
||||
3. `finalize-windows` depends on packaging and uses a second protected environment
|
||||
named `windows-installer-signing`. After approval it downloads the signed
|
||||
installer, regenerates its blockmap and `latest.yml`, runs existing outer and
|
||||
inner signature checks, uploads evidence, and uploads the assets to the draft.
|
||||
4. `publish-release` depends on finalization as well as the existing Linux, macOS,
|
||||
and blocking release gates. It remains the only job that publishes the draft.
|
||||
|
||||
The approver signs in SignPath, waits for that request to finish, and then
|
||||
approves the corresponding pending GitHub job. Each notification should link
|
||||
to both places and explain the order. GitHub approval is an extra action;
|
||||
approving in SignPath alone does not release an environment gate.
|
||||
|
||||
## Required configuration
|
||||
|
||||
The repository environments were inspected through the GitHub API on
|
||||
September 5, 2026. Neither Windows environment exists. `adhoc-mac-build` has no
|
||||
protection rules; it cannot be reused as an approval gate. No SignPath callback
|
||||
handler was found in the repository's workflows, scripts, application, or cloud
|
||||
code.
|
||||
|
||||
Before enabling the graph:
|
||||
|
||||
1. Create both environments in repository Settings → Environments.
|
||||
2. Add the release approvers as required reviewers for each environment. Decide
|
||||
whether a release initiator may approve their own job, and configure that
|
||||
consistently with the existing SignPath policy.
|
||||
3. Restrict deployment branches to the trusted refs used to dispatch release
|
||||
workflows, and check that the release workflow's ref passes the restriction.
|
||||
The workflow ref and the checked-out release tag are different concepts.
|
||||
4. Read back both environments through the API and verify that
|
||||
`required_reviewers` rules exist before changing the release graph. Merely
|
||||
referring to a new environment name in YAML can create an unprotected
|
||||
environment and silently leave the wait on the runner.
|
||||
5. Add a preflight assertion for those rules so accidental removal fails before
|
||||
any signing request is submitted. Verify the API access required for this
|
||||
assertion using the release workflow's token; do not assume an administrator's
|
||||
local `gh` access proves workflow-token access.
|
||||
|
||||
An automatic alternative requires a SignPath completion callback and an
|
||||
authenticated integration that releases the corresponding deployment gate.
|
||||
Confirm the Foundation plan supports the necessary callback before choosing
|
||||
that architecture. Do not introduce a long-running GitHub polling job as the
|
||||
callback substitute: it would continue occupying a slot.
|
||||
|
||||
## State and failure contracts
|
||||
|
||||
- Use artifacts from this exact run and attempt, with a manifest containing the
|
||||
tag, tag commit SHA, workflow SHA, request IDs, artifact IDs, and SHA-256 hashes.
|
||||
Artifact names alone are insufficient. Preserve the original unsigned
|
||||
installer for the existing inner-signing fallback.
|
||||
- Restore `dist/win-unpacked`, the staging list, the installer, and updater
|
||||
metadata as one checkpoint. Use an archive to preserve the tree. Do not ship
|
||||
a fresh rebuild of the app after approving a different binary tree.
|
||||
- Each new Windows runner needs the pinned Node/pnpm toolchain, build
|
||||
dependencies, SignPath module, and electron-builder tool cache. The second
|
||||
runner must populate the NSIS cache before replacing `elevate.exe`; the old
|
||||
code assumes the first installer build already populated that cache.
|
||||
- Retain checkout-from-tag behavior and the existing support for release tags
|
||||
that predate the composite action. Explicitly restore new orchestration code
|
||||
from the workflow SHA when necessary.
|
||||
- Preserve the rule that rerunning a workflow never submits a new signing
|
||||
request. A resume must consume the recorded request and artifacts. Test failed
|
||||
stage reruns, whole-workflow reruns, and missing/expired checkpoints separately.
|
||||
- Keep installer signature checks blocking. Keep inner verification evidence
|
||||
and its current warning-only policy unless changed in a separate decision.
|
||||
- Resolve the current one-hour inner-signing fallback deliberately: an
|
||||
environment approval can remain pending longer than one hour and rejection
|
||||
skips dependent jobs. It cannot reproduce the existing automatic timeout
|
||||
fallback by itself. A first migration should explicitly document the new
|
||||
manual release/cancellation behavior; silently treating rejected approval as
|
||||
permission to ship is not acceptable.
|
||||
- Keep the release-wide concurrency lock while the graph waits, preventing
|
||||
another release from overtaking this draft. This saves worker occupancy, but
|
||||
does not shorten the serialized release queue's human approval time.
|
||||
|
||||
## Validation before production
|
||||
|
||||
First adapt `windows-signing-rehearsal.yml` to exercise the same staged code
|
||||
using the auto-approved test-signing policy. Then run a manual rehearsal with
|
||||
the protected environments and confirm that pending approval has no allocated
|
||||
Windows runner. Verify signed bytes through the existing extraction-based
|
||||
installer checks, not only the outer installer signature.
|
||||
|
||||
Cover approval before SignPath completion, rejected approval, missing signed
|
||||
files, changed checkpoint hashes, lost checkpoints, expired artifacts, failed
|
||||
packaging, and stage reruns without duplicate submissions. Confirm no release
|
||||
becomes public until all platform and signature gates pass. Compare transferred
|
||||
artifact/setup time with the original 13m59s wait sample to measure net savings.
|
||||
|
||||
A separate `workflow_dispatch` continuation can avoid environment provisioning,
|
||||
but changes this design substantially: the original release run finishes,
|
||||
workflow-level concurrency no longer protects the pending draft, and SignPath
|
||||
must accept artifacts assembled from a prior run. That option needs a durable
|
||||
release state machine and provenance validation before production use; it is
|
||||
not a drop-in replacement for the two download steps.
|
||||
@@ -63,7 +63,7 @@ List every machine the current Orca host can target and the selector for each on
|
||||
orca host list --json
|
||||
```
|
||||
|
||||
The result includes this machine, its registered [SSH targets](/docs/ssh), and paired [Remote Orca Servers](/docs/remote-servers). Use `--host local` for this machine, `--host ssh:<target-id>` for an SSH target, and `--environment <server-name>` for a paired server. SSH labels and paired-server names also resolve when they are unique; use the IDs from `host list` when names collide. If you put a machine name on the wrong selector, Orca reports the matching machine and the flag to use instead of returning an empty result.
|
||||
The result includes this machine, its registered [SSH targets](/docs/ssh), and paired [Remote Orca Servers](/docs/remote-servers). Use `--host local` for this machine, `--host ssh:<target-id>` for an SSH target, and `--environment <server-name>` for a paired server. SSH rows include the detected remote platform (`linux`, `darwin`, or `win32`) after the target connects; older or disconnected targets report `platform unknown`. They also include `connected` and, when known, the SSH lifecycle `connectionStatus`. SSH labels and paired-server names also resolve when they are unique; use the IDs from `host list` when names collide. If you put a machine name on the wrong selector, Orca reports the matching machine and the flag to use instead of returning an empty result.
|
||||
|
||||
## Runtime commands
|
||||
|
||||
|
||||
@@ -253,6 +253,9 @@ export const electronViteConfig: UserConfig = {
|
||||
'agent-hooks/managed-agent-hook-controls': resolve(
|
||||
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
||||
),
|
||||
'codex/managed-home-shell-preflight': resolve(
|
||||
'src/main/codex/managed-home-shell-preflight.ts'
|
||||
),
|
||||
// Why: account import mutates the user's macOS Keychain from the CLI.
|
||||
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
|
||||
},
|
||||
|
||||
@@ -204,11 +204,18 @@ function escapeRegExp(value: string): string {
|
||||
}
|
||||
|
||||
function codePlaceholderPrefix(content: string): string {
|
||||
let prefix = CODE_PLACEHOLDER_PREFIX_BASE
|
||||
while (content.includes(prefix)) {
|
||||
prefix = `${prefix}_`
|
||||
let suffixLength = 0
|
||||
let cursor = 0
|
||||
while ((cursor = content.indexOf(CODE_PLACEHOLDER_PREFIX_BASE, cursor)) !== -1) {
|
||||
cursor += CODE_PLACEHOLDER_PREFIX_BASE.length
|
||||
const suffixStart = cursor
|
||||
while (content[cursor] === '_') {
|
||||
cursor += 1
|
||||
}
|
||||
// One extra underscore keeps the prefix longer than every authored run.
|
||||
suffixLength = Math.max(suffixLength, cursor - suffixStart + 1)
|
||||
}
|
||||
return prefix
|
||||
return CODE_PLACEHOLDER_PREFIX_BASE + '_'.repeat(suffixLength)
|
||||
}
|
||||
|
||||
function protectMarkdownCode(content: string): {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user