diff --git a/.gitattributes b/.gitattributes index 2a99890023b..8f4f884295d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,7 +8,19 @@ /src/cli/bundled-skill-guides.ts text eol=lf # Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash. /resources/plugins/** text eol=lf -# pnpm hashes every patch byte-for-byte, so a CRLF checkout breaks the install. +# Relay assets are copied verbatim into the bundle and hashed byte-for-byte into +# .version, which names the immutable remote install dir. A CRLF checkout makes a +# Windows-built client disagree with a mac/Linux-built one on the same release, +# so one host ends up with two relay trees (#17886 review). +/config/relay-assets/** text eol=lf +# Pin the bytes so a patch reads and diffs identically on every host. It is NOT +# what makes the hash right: pnpm hashes a patch LF-normalized, so a CRLF checkout +# cannot change it. Believing otherwise put a hand-computed raw digest in the +# lockfile twice and broke every install (#17886). +# These files are stored LF, which is not always the encoding they were written +# against -- @vscode/windows-process-tree ships CRLF sources -- so any code that +# runs `git apply` on one must force `-c core.autocrlf=input` rather than trust +# the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs. /config/patches/*.patch -text # The xterm bundle hunks also make a diff nobody can read; review the hand-written # source patch under xterm-src/ instead. The sibling patches stay diffable. diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index e36ec4c65d8..7695d2bec9b 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -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 diff --git a/.github/scripts/e2e-with-window-manager.sh b/.github/scripts/e2e-with-window-manager.sh new file mode 100644 index 00000000000..d431a039809 --- /dev/null +++ b/.github/scripts/e2e-with-window-manager.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +openbox --sm-disable > /tmp/orca-e2e-window-manager.log 2>&1 & +wm_pid=$! +cleanup() { + kill "$wm_pid" 2>/dev/null || true + wait "$wm_pid" 2>/dev/null || true +} +trap cleanup EXIT +ready=false +for attempt in {1..100}; do + if xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | rg -q 'window id # 0x[1-9a-fA-F]'; then + ready=true + break + fi + if ! kill -0 "$wm_pid" 2>/dev/null; then + cat /tmp/orca-e2e-window-manager.log + exit 1 + fi + sleep 0.1 +done +if [ "$ready" != true ]; then + echo 'Window manager did not acquire the Xvfb root window' >&2 + exit 1 +fi +"$@" diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index d7dd6d5ffb6..3e17eee9b68 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -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 diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index f0cc2df2bad..e2ba9407ac4 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -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 diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index e16a50f1c3c..89fda2ebef9 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -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." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3560d302a79..a94a7ea2ba5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,10 @@ on: description: Ref to check out (defaults to the workflow ref) required: false type: string + test_files: + description: JSON array of specs to run; empty runs the full suite + required: false + type: string schedule: # Why: GitHub cron uses UTC; these slots map to 10am and 3pm # America/Phoenix for the default-branch E2E run. @@ -146,7 +150,7 @@ jobs: # Native cache misses need the compiler, Electron needs Xvfb, and paired # Quick Open needs ripgrep. Install them in one apt transaction per shard. - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -167,7 +171,7 @@ jobs: # ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron # launches but never creates a BrowserWindow. - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }} # Why: Playwright retains traces/screenshots only on failure. Uploading # them as an artifact makes post-mortem debugging on CI possible without @@ -201,7 +205,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -241,7 +245,7 @@ jobs: if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then E2E_PROJECT_ARGS+=(--project=electron-headful) fi - xvfb-run --auto-servernum env "${E2E_ENV[@]}" \ + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \ pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - name: Upload Playwright traces @@ -278,7 +282,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh + run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -293,7 +297,7 @@ jobs: # Why: this is the release-path proof that the deployed Linux relay keeps # its PTY and explorer live across a real watcher SIGSEGV. - name: Run Docker SSH watcher isolation E2E - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation # Why: Playwright empties test-results/ when it starts, so each step here used to # destroy the previous step's traces. Only the last lane's failure was ever @@ -310,7 +314,7 @@ jobs: # readiness across live SSH, headed paired, and headless serve topologies. - name: Run Docker SSH terminal parking + startup readiness E2E if: always() - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking - name: Keep terminal-parking traces if: always() @@ -326,7 +330,7 @@ jobs: # legible as an SSH-named failure. - name: Run remaining Docker SSH E2E if: always() - run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker - name: Keep remaining-ssh-docker traces if: always() diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index d46c80033fa..11cfa866c67 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -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() diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index ac3af92a3bc..c300b2543b8 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -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 }} diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 59f6cf20bf4..6dbfc02aa3c 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -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 diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml new file mode 100644 index 00000000000..d45d8b8f45a --- /dev/null +++ b/.github/workflows/performance-contracts.yml @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a749214e232..bd655eb801a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 6f888c3a512..c2124d12990 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -809,13 +809,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.cut.outputs.tag }} - run: | - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Release $TAG already exists." - exit 0 - fi - - node config/scripts/create-draft-release.mjs "$TAG" + run: node config/scripts/create-draft-release.mjs "$TAG" terminal-rendering-golden: needs: cut @@ -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 + # (`\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- cache key with this workflow, so a + # test-certificate elevate.exe must never be staged into a release cache. $signature = Get-AuthenticodeSignature -FilePath $signed $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap." exit 0 } - $cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue) - if ($cached.Count -eq 0) { - Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.' - exit 0 - } - foreach ($file in $cached) { - Copy-Item -Path $signed -Destination $file.FullName -Force - Write-Host "Replaced $($file.FullName) with the SignPath-signed copy." + node config/scripts/replace-cached-nsis-elevate.mjs $signed + if ($LASTEXITCODE -ne 0) { + $message = 'Cached elevate.exe swap found nothing to replace; the rebuilt installer ships an unsigned UAC elevation helper (issue #7785).' + if ($env:GITHUB_STEP_SUMMARY) { + try { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows elevate.exe cache swap:** FAILED — $message" -ErrorAction Stop + } catch { + Write-Host "::warning::Could not write the elevate.exe swap verdict to the job summary: $_" + } + } + throw $message } - name: Rebuild NSIS installer from signed unpacked app @@ -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) { '' } 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 diff --git a/.github/workflows/release-ref-validation.yml b/.github/workflows/release-ref-validation.yml new file mode 100644 index 00000000000..6995f9db174 --- /dev/null +++ b/.github/workflows/release-ref-validation.yml @@ -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 diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml index 71fcf264f69..96de1101275 100644 --- a/.github/workflows/skill-update-roundtrip.yml +++ b/.github/workflows/skill-update-roundtrip.yml @@ -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: diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index b9957b2daa9..1ab905d8783 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -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 diff --git a/.github/workflows/terminal-perf.yml b/.github/workflows/terminal-perf.yml index 38d0a25bbb7..72a0fec8992 100644 --- a/.github/workflows/terminal-perf.yml +++ b/.github/workflows/terminal-perf.yml @@ -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 diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 54b751908dc..90ab8db137c 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -3,9 +3,11 @@ # Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed # inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires # a two-request flow: sign the unpacked PE files first, then build the NSIS -# installer from the signed tree, then sign the installer. This workflow -# rehearses that entire flow from a branch, end to end, without publishing -# anything — so the release pipeline on main is never at risk while we verify. +# installer from the signed tree, then sign the installer. The NSIS uninstaller +# rides that same first request — it is captured through electron-builder's sign +# hook and swapped back in during the rebuild — so it adds no third approval. +# This workflow rehearses that entire flow from a branch, end to end, without +# publishing anything — so the release pipeline on main is never at risk. # # Runs only via manual dispatch. Use the test-signing policy for iteration # (auto-approved test certificate) and release-signing to rehearse the @@ -81,15 +83,27 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=4096 - - name: Package unpacked Windows app + # Why a full --win build and not --dir: the NSIS uninstaller only exists + # inside the installer build, and it is the file the MDE update cluster + # flags. --dir would never produce it, so the rehearsal would not rehearse + # the uninstaller leg at all. This mirrors release-cut's first Windows pass. + - name: Package Windows app and export the NSIS uninstaller shell: pwsh + env: + # runner.temp, never the workspace: the all-negation `files` list in + # config/electron-builder.config.cjs packs whatever is left in the + # checkout root into app.asar. + ORCA_WIN_UNINSTALLER_EXPORT_PATH: ${{ runner.temp }}\uninstaller-signing\unsigned\orca-uninstaller.exe run: | node config/scripts/ensure-native-runtime.mjs --runtime=electron if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) { - throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe' + throw 'electron-builder --win did not produce dist/win-unpacked/Orca.exe' + } + if (-not (Test-Path -LiteralPath $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH)) { + throw "The sign hook did not export the NSIS uninstaller to $env:ORCA_WIN_UNINSTALLER_EXPORT_PATH" } # Why: only unsigned PE files go to SignPath. Files that already carry a @@ -132,6 +146,17 @@ jobs: Write-Host "Skipped $($skipped.Count) already-signed files:" $skipped | ForEach-Object { Write-Host " $_" } + # Why kept out of inner-signing-list.txt: that list drives the copy-back + # into dist/win-unpacked, and the uninstaller does not live there — it is + # re-injected through the electron-builder sign hook during the rebuild. + # No catch here, unlike the release job: the rehearsal exists to prove + # the flow, so a staging failure must fail it loudly. + $exportedUninstaller = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\unsigned\orca-uninstaller.exe' + $uninstallerStagePath = Join-Path $stage.FullName 'uninstaller\orca-uninstaller.exe' + New-Item -ItemType Directory -Force -Path (Split-Path $uninstallerStagePath) | Out-Null + Copy-Item -LiteralPath $exportedUninstaller -Destination $uninstallerStagePath -Force + Write-Host 'Staged the NSIS uninstaller for signing: uninstaller\orca-uninstaller.exe' + - name: Upload unsigned inner binaries for SignPath id: upload-unsigned-inner uses: actions/upload-artifact@v7 @@ -200,8 +225,27 @@ jobs: throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." } + - name: Restore signed uninstaller for the installer rebuild + shell: pwsh + run: | + $signed = Get-ChildItem -Path signed-inner -Recurse -File -Filter 'orca-uninstaller.exe' | + Select-Object -First 1 + if ($null -eq $signed) { + throw 'SignPath did not return uninstaller/orca-uninstaller.exe; check the inner-binaries artifact configuration covers it.' + } + $signature = Get-AuthenticodeSignature -FilePath $signed.FullName + if ($null -eq $signature.SignerCertificate) { + throw 'The returned NSIS uninstaller carries no signature.' + } + $signedDir = Join-Path $env:RUNNER_TEMP 'uninstaller-signing\signed' + New-Item -ItemType Directory -Force -Path $signedDir | Out-Null + Copy-Item -LiteralPath $signed.FullName -Destination (Join-Path $signedDir 'orca-uninstaller.exe') -Force + Write-Host ("{0,-14} uninstaller <{1}>" -f $signature.Status, $signature.SignerCertificate.Subject) + - name: Build NSIS installer from signed unpacked app shell: pwsh + env: + ORCA_WIN_UNINSTALLER_SIGNED_PATH: ${{ runner.temp }}\uninstaller-signing\signed\orca-uninstaller.exe run: | pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -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) { '' } 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-`, and that guard is + # the only thing stopping a test certificate from being seeded into + # the cache a real release restores from. Shipping users a binary + # signed by "Test certificate for 'Orca agent ide [OSS]'" is worse + # than shipping it unsigned. + # + # Fixing elevate.exe belongs in its own PR - it is a UAC elevation + # helper, and it deserves more scrutiny than a footnote in an + # uninstaller change. + if ($relative -eq 'resources\elevate.exe') { + Test-Signature "installed: $relative" $path -Advisory + } else { + Test-Signature "installed: $relative" $path + } } + if ($advisories.Count -gt 0) { + $report.Add('') + $report.Add('ADVISORY (known pre-existing, did not fail this run):') + $advisories | ForEach-Object { $report.Add(" $_") } + } Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n") if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host "::error::$_" } throw "Signing rehearsal failed with $($failures.Count) problems." } - Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed." + Write-Host "All checked binaries are signed, including the uninstaller the installer writes to disk ($($advisories.Count) advisory)." - name: Upload rehearsal evidence and installer if: always() diff --git a/.gitignore b/.gitignore index 8be3fc5b6f4..6722fc5ae54 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,8 @@ docs/** !docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-cmd-shim-resolution.md +!docs/reference/windows-daemon-host-relocation.md !docs/reference/windows-edr-posture.md !docs/reference/windows-process-enumeration.md !docs/reference/wsl-runner-verification.md diff --git a/.oxlintrc.json b/.oxlintrc.json index 77a7e43e807..03cc659f494 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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", diff --git a/AGENTS.md b/AGENTS.md index 8b0156ba6b1..b0947da0c2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,12 @@ All UI work — layout, color, typography, spacing, component selection, UX beha ## Electron UI Validation +Always run tests and agent-launched apps in the background with `ORCA_BACKGROUND_LAUNCH=1`. +Never steal monitor focus or reveal test windows: no `show()`, `showInactive()`, `bringToFront()`, +`app.focus()`, or OS activation. Use CDP screenshots of hidden renderers. Keep native-focus and +visible-window tests paused on the user's desktop; run them on an isolated display or CI. +Rebuild modified launch-policy code before running an app; stale build wrappers are not safe. + Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation. # Style @@ -47,8 +53,9 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. - **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). -- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. +- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one. - **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md). +- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md). - **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them. - **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. diff --git a/cloud/apps/relay/src/host-session-client-accept.test.ts b/cloud/apps/relay/src/host-session-client-accept.test.ts new file mode 100644 index 00000000000..83b6c21f997 --- /dev/null +++ b/cloud/apps/relay/src/host-session-client-accept.test.ts @@ -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(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((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 + } + ).activate.bind(registry) + return { registry, store, assignments, acquireActivity, releaseActivity, observer, activate } +} + +async function activeHost(h: ReturnType): Promise { + 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() + 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() + 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 } + store.resolveInviteForMove = vi.fn().mockResolvedValue(null) + const slowResume = deferred() + h.store.resolveResume.mockReturnValueOnce(slowResume.promise) + const resolveAssignment = (h.assignments as unknown as { resolve: ReturnType }) + .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 }) + .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 + } + ).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) + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 5c53041e7ff..1b7ed3df4af 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -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() private readonly activationQueues = new Map>() @@ -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(), diff --git a/cloud/apps/relay/src/relay-observability.test.ts b/cloud/apps/relay/src/relay-observability.test.ts index 2b9ceb0b72a..fc8a4fcb4af 100644 --- a/cloud/apps/relay/src/relay-observability.test.ts +++ b/cloud/apps/relay/src/relay-observability.test.ts @@ -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 }) }) diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index 2266217d607..59ff437e40b 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -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 controlClosesByCode: Record spliceClosesByTrigger: Record + clientAcceptsAbandonedByStage: Record + clientAcceptAbandonedMsMax: number controlRenewalLatenciesMs: number[] controlRenewalsByOutcome: Record 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)), diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 32a83962d81..6331b584b1b 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -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, diff --git a/cloud/docs/relay-improvement-checklist-2026-09.md b/cloud/docs/relay-improvement-checklist-2026-09.md new file mode 100644 index 00000000000..91f1cc742ef --- /dev/null +++ b/cloud/docs/relay-improvement-checklist-2026-09.md @@ -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`. diff --git a/cloud/docs/relay-improvement-roadmap-2026-09.md b/cloud/docs/relay-improvement-roadmap-2026-09.md new file mode 100644 index 00000000000..64f33c69a70 --- /dev/null +++ b/cloud/docs/relay-improvement-roadmap-2026-09.md @@ -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). diff --git a/cloud/docs/relay-reconnect-2026-09-findings.md b/cloud/docs/relay-reconnect-2026-09-findings.md new file mode 100644 index 00000000000..580a4da84d8 --- /dev/null +++ b/cloud/docs/relay-reconnect-2026-09-findings.md @@ -0,0 +1,1002 @@ +# Relay reconnect investigation: findings and evidence + +Working notes for the 2026-09-04 mobile relay reconnect incident and the cell roll that follows. +Kept current across context compactions. Newest section first. All times UTC. Host ids are log digests, +never raw ids. Nothing here is a production mutation record unless the "Mutations" section says so. + +## Status board + +| Item | State | Where | +|---|---|---| +| PR #18565 relay accept abandonment + lease jitter + desktop rotation spread + phone probe fail-fast | Open, CI fully green again after the doc move (05:45Z), CodeRabbit + Pullfrog cleared, 3 review rounds; not merged (owner has not asked) | https://github.com/stablyai/orca/pull/18565 | +| PR #18569 monitor `relayPostgresRetryExhausted` 0 -> 300 | **Merged** 2026-09-04 ~04:20Z as 4101505b6b | https://github.com/stablyai/orca/pull/18569 | +| Same-cap `verify` of c7 (read-only) | **Passed** run 33836527159 | confirms identities, selector gen 110, rehome gen 12, protocol 1, digests | +| Monitor dry-run #1 | Froze min 5: `relay.postgres_retries` 380 > 300 | run 33836470590 | +| Monitor dry-run #2 | Green to min 13, froze 04:49Z: `director.concurrency` 76.7 > 64 (six-cell crash storm, Finding 6) | run 33837160275 | +| Monitor dry-run #3 | Froze min 3 at 05:01Z: `relay.postgres_retries` 339 > 300; no crash, concurrency 5–8 | run 33838698725 | +| Owner decision 2026-09-04 ~05:10Z | **Option B approved**: "you can raise the bar. or remove it altogether ... whats the most logical move". Kept the bar (removal would leave contention unwatched during the roll) and recalibrated from measured data. | this thread | +| PR #18580 monitor `relayPostgresRetries` 300 -> 2000 | Open, awaiting CI; mutation-checked (300 fails the new test) | https://github.com/stablyai/orca/pull/18580 | +| PR #18565 CI | Was red on `root directory guard` because this findings file sat at repo root; moved to `cloud/docs/` in 8ebff89106 | | +| PR #18580 | **Merged** 2026-09-04 05:23Z as 79d5fb469a (Pullfrog cancelled by the merge; independent Opus review requested instead, per owner) | | +| Monitor dry-run #4 | Froze min 12 at 05:37:35Z: `cell.production-gce-c27.health`/`.ready` = 0. Retries green all 12 samples under the new 2000 bar. Cause: c27 (asia-east2) container died 3x 05:37:00–05:38:01Z, Finding 6 crash class. | run 33840364323 | +| Monitor dry-run #5 | Froze at sample 1 (05:41Z): c27 health/ready still 0. MIG autoheal `recreateInstance` on c27 fired 05:38:12Z after the 3 crashes; instance RECREATING, process up with 0 controls (was ~395). Second c27 recreate in 7 h (Finding 3 seed pattern). Waiting for c27 to settle before dry-run #6. | run 33841327879 | +| Monitor dry-run #6 | **Passed** 06:06:31Z: 16 samples, no freeze (started 05:47:42Z) | run 33841783747 attempt 1 | +| c7 `canary-apply` | **Succeeded.** Dispatched 06:07:15Z; drain 06:10Z; MIG recreate 06:16–06:23Z; new image listening 06:23:42Z; verify + trust proof passed; restored to `admission=general` 06:25:21Z; canary authority sealed. c7 is on `85bf6799…`. | run 33843071283 | +| PR #18581 doc reconcile (Aug 23 figure: 2,200–3,000 raw log lines vs 1,510 on the gate metric) | **Merged** | https://github.com/stablyai/orca/pull/18581 | +| Same-cap `verify` c7 target=519f4914 rollback=85bf6799, gen 112 | **Passed** (read-only) | run 33856355648 | +| Monitor dry-run #7 (gen 112) | Froze at sample 1 (09:05:31Z): `director.errors` 4 > 0, the four 2.0 s pg-connect 500s from the 09:00 cascade still inside the 5-min delta window. Dispatched 4 min too early. | run 33856521278 | +| Monitor dry-run #8 (gen 112) | Green for 15 of 16 samples (09:09:38–09:24), froze on the final sample 09:25:22Z: `director.errors` 1 > 0. The one 500 was `/v1/admin/evacuation-status` at 09:23:50Z, 2.01 s latency = director pg-connect timeout, called by **the monitor's own collector** (`incident-monitor-sources.ts:492`). First evacuation-status 500 since Sep 1. The gate froze on a request it made itself. | run 33856905229 | +| Monitor dry-run #9 (gen 112) | Froze: c13/c23 crashed 50 s after dispatch, then c14/c20/c9 at 09:34. | run 33858650691 | +| Monitor dry-run #10 | Dispatched 09:46:13Z; froze at sample 5 (09:56:59Z): `director.errors` 12. All twelve at 09:55:17–21Z, 0.8–2.1 s latency, 10 on `/v1/regions` + 2 on `/v1/assign`; c16 and c8 crashed at 09:55:19 in the same second. A single 4-second Postgres connect stall hit director and cells together. | run 33859947207 | +| Monitor dry-run #11 | Froze at sample 2 (10:08:07Z): `director.concurrency` 79.8 > 64, the c8/c20 re-dial. They crashed 10:05:54, 3 s before the waiter's quiet check passed (log ingestion lag). | run 33861578009 | +| Monitor dry-run #12 | Dispatched 10:17:38Z after 10 quiet min; froze at sample 2 (10:19:24Z): `cell.production-gce-c16.health` 0. c16 did **not** crash (no container die, MIG NONE/HEALTHY, readiness=true throughout, `/health` 200 in 230 ms at 10:21). At 10:19:07–16 it logged "control activity renewal failed" x4 and a burst of 1006 closes, sqlFailures 1 -> 14, sqlLatencyMsMax 2588: a pg stall on the old image that did not reach the unhandled path. The probe's single fetch (30 s timeout) came back unavailable during that stall and `unavailableIsZero` turned it into health=0. | run 33862504601 | +| Monitor dry-run #13 | Green 14 of 16 samples (10:48:38–11:03), froze 11:04:43Z: c9 crashed 11:04:23, c28 11:04:25 (then looped 11:05:04, 11:05:41); c15 probe also read 0 (stall, no crash). Missed by ~90 s. **Dispatched by hand 10:48:15Z** into a 43-min crash lull (last die 10:05:54; last director 500 10:31:49). The re-armed waiter never fired: its MIG-stable check used `grep -vc True`, which exits 1 when nothing matches, so `&&` short-circuited on the *healthy* case. Waiter armed 10:20Z: 10-min quiet + every MIG stable + 60 s recheck, then dispatch, then canary c7 on green. Held at 10:24 and 10:31 by lone director `/v1/assign` 500s (2 s pg-connect stalls, no cell crash). Director 500 events since 08:46: 6 (gaps 2.7/21/31/29/7.6 min). At 10:39 the waiter was re-armed with a 6-min director-500 window (the monitor's own delta is 5 min) instead of 10, since the gate only needs the 15 min *after* dispatch to be clean. Cell crashes have stopped since 10:05 (33+ min, longest gap since 08:40). 12 dry-runs: 1 pass (#6), 11 freezes, none on a real fleet-health regression. | Cascade gaps since 09:00: 31, 2.9, 5.1, 16.1, 4.0 min (median 5); a 15-min clean window is ~28% per attempt at this rate. | | +| Monitor dry-run #14 | Dispatched 11:26:53Z by the fixed waiter (first autonomous dispatch); c14, c23, c25, c15, c24, c19 died 11:30:59–11:31:08 (six cells, 13 min after the last cascade). Froze on c8 (and others) health/ready probes. Waiter re-armed 11:06Z (grep bug fixed: `grep -c` under `|| true`), same chain; held through the 11:17 cascade and c14/c28 recreates. 13 dry-runs: 1 pass, 12 freezes. Since 08:40: 10 cascades, 75 container dies, gaps 20/31/3/5/16/4/6.5/58/13 min; only 3 windows of >=17 clean minutes existed in 2.6 h, and dry-runs hit two of them (#6 passed, #13 lost the third by 90 s). | +| Monitor dry-run #15 | Waiter armed 11:33Z (6-min director-500 window, 8-min crash window, all MIGs stable), chained canary; still holding at 12:04Z. Since 11:00: 8 cascades, 98 dies, gaps 13/13.6/3.6/14.5/4.4/6.1/3.0 min, **max gap 14.5 min**, so no 15-min clean window has existed in the last hour. 14 dry-runs: 1 pass, 13 freezes. | +| Monitor dry-run #15 verdict | Dispatched 12:28:49Z; froze at sample 2 (12:30:41Z): **12 cells** health/ready = 0 at once (c4, c5, c7, c10, c15, c16, c18, c20, c22, c25, c27, c28), including c4/c5 (0 controls all day, `/health` 200 in 190 ms a minute later) and c7 (new image). Six old-image cells also crashed 12:30:02–21. This was a fleet-wide SQL stall, not a cascade: every cell's `sqlLatencyMsMax` hit 4–6 s (c7 4865, director 5140), director pool waiting 1258, 15 cell pg-connect timeouts, director sqlFailures 92. Cloud SQL CPU 0.73, backends 160, new connections normal, memory 0.46, so the *instance* was not saturated; something held the database for ~5 s. Postgres log 12:31:23–28 shows a burst of `could not obtain lock on row in relation "relay_cells"` from NOWAIT (single-row and full-inventory) sweeps, i.e. the row locks were held during recovery. Cloud SQL transactions/min flat (~30k), reads flat, +network flat: the database was neither busy nor saturated, it was *waiting*. The stall bracket +(12:30:02–12:30:41) is where every cell's SQL max hit 4–6 s at once. Lock retries in that window were +ordinary (49/29/13 per min). Best reading: a ~5 s Postgres-side wait event shared by every session +(lock on a hot row held across a long transaction, or an instance-level pause), not CPU/IO. Cell +`sqlLatencyMsMax` was already 1.5–2.2 s fleet-wide in the four minutes before, i.e. the old cells' 1 s +`lock_timeout` plus queueing. | run 33872946111 | +| Monitor dry-run #16 | Dispatched 12:38:57Z; froze at sample 1 (12:40:11Z): `cell.production-gce-c27.latency_ms` 2071 > 2000, a fifth distinct freeze signal, the probe's own round-trip absorbing a checkpoint sync. **Loop stopped by me at 12:41Z**: with the disk in the checkpoint loop (Finding 10) no bar can hold for 15 min, so further dry-runs only burn the shared rollout lease. 16 dry-runs: 1 pass, 15 freezes. Re-arm after the disk change lands. | +| Cloud SQL checkpoint loop | **Broke on its own 12:39–12:45Z**: disk writes 48 -> 4 MB/s at 12:39 with transactions and network flat and no Cloud SQL operation; 12:40:17 checkpoint synced 0.047 s; 12:45:53 checkpoint was `time`-triggered again (first since 11:55) with sync 0.096 s and write spread over 269 s. Cause of the break unknown (most likely WAL fell back under `max_wal_size` once a burst of full-page writes aged out). It can re-enter the loop on the next large checkpoint; the disk-size fix remains the durable one. | +| Monitor dry-run #17 | Dispatched ~12:49Z (all guards clean); froze at sample 1 (12:52:05Z): `director.errors` 4, from the c9/c22 crash loop that began 12:50:34, ~90 s after dispatch. Checkpoints stayed healthy (85 ms), so this is the old image's baseline crash rate, not the disk. 17 dry-runs: 1 pass, 16 freezes. | +| Monitor dry-run #18 | **Dispatched by mistake 13:48:56Z into the outage**: my gcloud credentials expired ~13:45Z, every guard query returned empty, and the waiter's `grep -c . || true` read empty as "quiet". Froze at sample 1 (13:49:43Z) on `director.ready=0`, `auth.health=0`, and cell probes; no canary dispatched, no production mutation. All waiter loops killed at 13:51Z. Lesson: a quiet-window check must fail closed when its data source errors. Waiter had been re-armed 12:53Z. | +| Gate decision | Owner asked at 09:36Z to choose: A keep looping / B recalibrate `directorErrors` 0 -> small n / C human bypass. Ten dry-runs, four froze on this bar. Recommendation B+A. Note: B alone would not have passed #9 or #10 (cell health probes and a 12-error burst); it fixes the single-500 false freezes (#7, #8) only. | | +| Batch roll | **Deferred by plan**: roll once with the lock-fix image instead of twice. | | +| PR #18606 lock removal (root cause) | **Merged** 09:2xZ as 7b108abf71 after review, fix, re-verify; CI green | https://github.com/stablyai/orca/pull/18606 | +| Image publish for 7b108abf71 | **Done** 08:36:49Z run 33854111305: `sha256:519f4914217f08cabcdcd34825965db8473ec37c6591553a3af0d65dcdeeb183` | | +| Director deploy on 519f4914 | **Succeeded** 08:45Z run 33854355791; serving `orca-cloud-relay-00570-siv`, rollback tag on 00569-ret (also 519f4914), 00565-fes (85bf6799) still deployable. Dispatched 08:37:45Z (blue/green; prior revision 00565-fes on 85bf6799 kept as rollback). Note: `predecessor-image-digest` is a required input even with bootstrap=false; pass the serving digest. | `cloud-deploy-relay-production-director.yml` | +| c7 on new image, 2 h in | 817 controls, **0 container die** since restore (was ~1 per 15 min on old image); `sqlLatencyMsMax` still 1.0 s = lock wait unchanged, which #18606 targets | | +| Terraform alert `relay_postgres_retry_exhausted` at `> 0` | Firing continuously since #18521; recalibration not done (own change) | `cloud/infra/terraform/relay-observability.tf:447,469` | + +## Mutations performed (complete list) + +1. Merged PR #18569 to main (code/docs only). +2. Merged PR #18580 and #18581 to main (monitor bar + docs). +2b. Merged PR #18606 to main (relay lock change; no serving effect until the image is deployed). +2c. Dispatched `cloud-publish-relay-production` for 7b108abf71 (builds and pushes an image; changes nothing serving). Done: 519f4914. +2d. Dispatched `cloud-deploy-relay-production-director` on 519f4914 (preserve placement, no prune, rehome gen 12). Succeeded 08:45Z; serving revision 00570-siv. Rollback: `gcloud run services update-traffic orca-cloud-relay --region us-central1 --to-revisions orca-cloud-relay-00565-fes=100` (85bf6799, still Ready). Not needed so far. +3. 2026-09-04 06:07:15Z: dispatched `cloud-deploy-relay-production-same-cap` `canary-apply` for production-gce-c7 only (run 33843071283). Completed successfully 06:26Z: c7 isolated, drained (807 controls re-dialed), template + MIG rolled to 85bf6799, verified, restored to general admission. Selector generation advanced 110 -> 112 (isolate + restore). +4. Nothing else. Both monitor dispatches were `mode=dry-run` (read-only). The same-cap dispatch was `mode=verify` (read-only, confirmed by step gates `if: inputs.mode != 'verify'` on every mutating step). + +## Finding 6 (2026-09-04 ~05:00Z): the old cell image crashes the whole process on a Postgres connect timeout + +**This is the most important open finding.** The 23 GCE cells run image `sha256:5aedbca5…` = orca-cloud +commit e3e92d95d3 (2026-08-14). In that build `beginProof` is called as `void this.beginProof(...)`. +When `verifyCellAssignment` inside it throws (pg-pool `timeout exceeded when trying to connect`, 2 s +`connectionTimeoutMillis`), the rejection is unhandled and Node exits 1. Docker restarts the container +in ~1 s, but every control on that cell (~800 hosts) drops and re-dials `/v1/assign` at once. + +Evidence, cell c7 instance 4545742188814054238, 2026-09-04: + +``` +04:46:47.951 stderr [orca-relay] control activity renewal failed (x5) +04:46:49.527 stderr Error: timeout exceeded when trying to connect + at pg-pool/index.js:45:11 + at async PostgresPoolPressure.connect (postgres-pool-pressure.js:30:20) + at async PostgresDatabase.query (database.js:645:24) + at async RelayAssignmentStore.verifyCellAssignment (assignment-store.js:2024:22) + at async HostSessionRegistry.beginProof (host-session-registry.js:376:15) +04:46:49.527 stderr Node.js v24.19.0 +04:46:49.835 dockerd: container die … exitCode=1 image=…relay@sha256:5aed… +04:46:50.258 dockerd: container start +04:46:52.761 stdout [orca-relay] listening on https://c7.relay.onorca.dev +``` + +2026-09-04 05:36:59–05:38:01Z: c27 died 3x in 62 s plus one other instance (5464389947731541178); this froze dry-run #4 on c27's health probe. + +Fleet-wide `container die … exitCode=1` on the relay image, last 48 h: **201 events on 19 instances** +(c28 x38, c29 x37, c27 x19). Hourly counts track the lock-contention curve (peak 23/h at 21Z Sep 3). +Every one has the same `Node.js v24…` crash banner. On 2026-09-04 04:46:35–04:47:41Z six cells +(c7, c8, c19, c21, c22, c25) died within 66 s: ~4,800 hosts re-dialed, `/v1/assign` returned 16,321 +503s in one minute (baseline ~20), director concurrency hit 85 (Cloud Run cap 80), Cloud SQL +`new_connection_count` 119 -> 287/min. Fleet recovered by 04:51Z. That is what froze dry-run #2. + +Fix status: `guardSessionTask` wrapping `beginProof` landed in orca-cloud #436 (2026-08-27) and is in +the target image `sha256:85bf6799…` (main 11aace8dec). The roll is the fix. Not caused by anything in +this session: the same-cap verify finished ~04:25Z and never reached a mutating step; no compute +operations exist for those instances; heap/event-loop were flat before the crash. + +Autoheal amplifier: MIG health check is `/health` every 10 s, timeout 5 s, unhealthy after 3, so a +crash loop of ~30 s+ triggers `compute.instances.repair.recreateInstance`. All ~20 recreates in the +48 h to 2026-09-04 05:40Z were the three Asia cells (c27 x6, c28 x7, c29 x8; gcloud prints local +-07:00 times). c27 recreated 05:38:12Z after 3 crashes in 62 s; its ~395 controls went to 0 and the +monitor's `cell.production-gce-c27.health/ready` probe read 0 for the whole recreate (~several min), +freezing dry-runs #4 and #5. Each recreate also seeds a Finding 3 rotation cohort. Rolling the Asia +cells early in the batch phase should be weighed against the canary-first rule; c7 stays the canary. + +Implication for the gate: the monitor's `director.concurrency` freeze is *correctly* detecting these +crash storms. A dry-run only passes in a 15-minute window with no cell crash, roughly 1 in 3 windows +at current rates. Retrying in quiet hours is legitimate; the bar is not wrong. + +## Finding 5: `relay.postgres_retries` at 300 is 3x under today's baseline + +Retries per 5 min, cells + director, last 24 h: p50 579, p90 1039, p99 1398, max 1505; **65% of +windows over 300**. Quiet hours (03–08Z) p50 235, max 512. When the 300 bar was set (2026-08-26) +healthy bursts reached 234. Baseline has roughly tripled in 10 days. Skill notes say do not raise this +bar; I have not. Best odds for a clean 15 min are 02–04Z and 17–18Z (9/12 five-minute windows under +300 in each). + +## Finding 4: exhausted-retry bar was the wrong single blocker (fixed) + +`relayPostgresRetryExhausted: 0` never cleared after #18521 reached the director (22:12Z Sep 3): 236/236 +five-minute windows non-zero; post-#18521 p50 42 / p90 147 / max 220; Aug 23 incident peak 467. +Recalibrated to 300 in #18569 (merged). Dry-run #1 immediately revealed Finding 5 behind it. + +## Finding 3: the 00:50Z control-close wave was desktop lease rotation, not a rollout + +2026-09-04 00:49–00:51Z: 2,745 control closes on 19 instances; 1157/1632 code 1006 and 973/1030 code +4408 `control rebound` had ageMs in the 53-minute bin. Relay grants a flat 55 min lease; desktops +rebind 60–120 s early; so every host that (re)connected in the same minute rebinds as one cohort +forever. Seed: c27 MIG autoheal recreate 23:23Z (`compute.instances.repair.recreateInstance`) dumped +~420 controls. Harmonics at 23:55, 00:04, 00:25, 00:49Z. Each rebind is an `activateControl` +transaction that can take the inventory lock. Fix in #18565: relay lease 55 min ± 5 min (symmetric, +so mean rebind rate unchanged), desktop early window 1–6 min. + +## Finding 2: fleet-wide lock contention, worse on Sep 3 + +| window | 55P03 retries/h (cells) | cell sqlFailures/h | +|---|---|---| +| Sep 2 18Z – Sep 3 07Z | 660–1470 | 680–1620 | +| Sep 3 08Z–16Z | 3600–7100 | 3700–7700 | +| Sep 3 23Z | 7468 | 7585 | + +100% of sampled retries are 55P03; director phase is `cell-inventory`. Every cell pins +`sqlLatencyMsMax` at 1.0–1.2 s = the pre-#18521 1 s pool `lock_timeout`. Not load (controls flat +~26k, Cloud SQL CPU 46–53%). No `cloud-*` workflow explains the 08Z step. The lock is a global +`SELECT * FROM relay_cells FOR UPDATE` (23 rows) taken by assignment, control activation, activity +acquire, and sweeps, held to COMMIT. + +## Finding 1: root cause of the phone's 24 s hang (the original symptom) + +`acceptClient` runs four serialized Postgres calls; the fourth (`acquireActivity`) contends for the +global lock. Under contention the cell finishes after the phone's 12 s bound, then +`PendingHostDataReservation.bind` throws `host_data_reservation_already_bound` because the phone's +close already released the reservation. Every "first frame handler failed already_bound" line is that +post-mortem (31 events 23:06–01:01Z across 12 instances). Fix in #18565: abandon the accept after each +DB step once the socket is closed; new event `orca_relay_client_accept_abandoned {stage, elapsedMs}` +and metric fields `clientAcceptsAbandonedByStageDelta` / `clientAcceptAbandonedMsMax`. Phone side: +direct probe now fails fast on `reconnecting` so relay recovery is not queued behind three doomed +LAN redials (~3.5 s saved per foreground). #18518 (merged, not yet on the phone) covers the +stage-aware dial bound. + +Host 666077865f2e: stable throughout. 4408 rotation 00:27:45Z; 1006 quit 00:52:24Z on old adhoc; +sticky reassignment to c27 00:52:35Z on new build; rotation closes 01:44:55Z and 02:23:15Z with +splices intact. No drain/4404/wrong-cell. + +## Finding 7 (2026-09-04 ~05:10Z): retries bar recalibration basis (PR #18580) + +Chose 2000 over removal. The metric is the gate's own source (`orca_relay_postgres_retries` +log metric, director + cells summed per five minutes, ALIGN_DELTA 300 s): + +| window | p50 | p90 | p99 | max | > 300 | +|---|---|---|---|---|---| +| 2026-09-01 | 56 | 105 | 206 | 456 | 0% | +| 2026-09-02 | 109 | 186 | 294 | 377 | 1% | +| 2026-09-03 | 430 | 924 | 1320 | 1504 | 55% | +| 2026-09-04 to 05Z | 285 | 1012 | 1211 | 1211 | 44% | + +15-minute pass rate, last 24 h: bar 300 -> 22%, 800 -> 66%, 1000 -> 86%, 1500 -> 99%, 2000 -> 100%. +Aug 23 incident on this metric: 1510 then 646 (single windows), so retries no longer separate an +incident from baseline; exhausted (467 vs bar 300; healthy 72 h max 184), director concurrency, +and pool bars carry that role. Note: my earlier "p99 1398 / 65% over 300" in Finding 5 came from +raw log line counts; the metric-based numbers above are what the gate actually evaluates. +Baseline tripled between Sep 2 and Sep 3 with no deploy; still unexplained (Finding 2). + +## Decision needed from the owner (resolved: B) + +The same-cap roll is blocked only by the monitor gate, and the gate is blocked by `relayPostgresRetries: 300` +(Finding 5: 65% of windows breach it; even the 04:55Z quiet window hit 339). Three options: + +- A. Keep waiting for a naturally quiet 15 min. Odds per attempt ~1 in 3 in quiet hours, lower by day. + Each attempt is free and read-only. Could take hours. +- B. Recalibrate `relayPostgresRetries` from measured data, same method as #18569: 24 h p99 is 1398, the + Aug 23 incident ran 2200–3000, so ~1500 clears healthy windows with ~1.5–2x incident separation + (less margin than the exhausted bar had). Overrides the "do not raise" note in the skill facts. + Argument for: the roll being gated is the thing that reduces retries. Argument against: the bar is + doing its job of saying contention is high. +- C. A human dispatches the roll with a different gate policy. Not something I can or should do. + +My recommendation: B, with the number chosen from the table in Finding 5 and the roll following +immediately so the bar can be re-tightened after the fleet is on the 500 ms lock wait. + +## Finding 12 (2026-09-04 13:12Z): **INCIDENT IN PROGRESS. The auth service is at its 2-instance cap and rejecting 90% of desktop token calls with 429; the relay fleet has emptied.** + +Timeline: 13:04–13:06 the old-image cascades and NAT stalls drove ~1,400 desktops to re-dial. Their relay +JWTs (5-min TTL) expired mid-storm, so they hit `orca-cloud-auth` `/v1/desktop/auth/refresh` and +`/v1/desktop/auth/relay-token` together. The auth service is Cloud Run `maxScale=2`, `concurrency=80`, +1 vCPU throttled (`auth_max_instances = 2` in orca-cloud `infra/terraform-apps/environments/production.tfvars`, +applied by `deploy-auth-production.yml`). Both instances pinned at concurrency 85 from 13:02; from 13:07 +Cloud Run's front door returns **429 "no available instance"** (0 s latency, never reaches the container): +12,045 at 13:07, 54,292 at 13:08, 46,025 at 13:08, 42,529 at 13:09. Sep 3 total auth 429s: **0**. +Without a fresh relay token every desktop's `/v1/assign` gets 401 (1,433 distinct hosts 401'd, 0 got 200 +since 13:07) and every cell closes its control with `4401 relay authorization expired`. Fleet controls: +13,375 (12:55) -> 7,633 (13:08) -> **249 (13:12)**, splices 1. Auth container CPU 0.15–0.5, so the cap is +the limit, not the code. Every desktop is now in its refresh-retry loop hammering the same 2 instances: +this is a self-sustaining thundering herd and will not clear on its own. At 13:14Z: fleet **30 controls** +across 23 cells; successful relay-token issuance 5,000–6,500/min until 13:05, then 1,059 / 734 / 733 / +443 / 220 / 214 / 148 / **4** per minute through 13:13; auth 429s 54k -> 25k/min only because desktops +are backing off, not because the service recovered. Note `AUTH_MAX_INSTANCES: 2` is also hardcoded in +orca-cloud `.github/workflows/deploy-auth-production.yml` (lines 33–34), so a redeploy would re-pin it; +change both the workflow env and the tfvars. + +**Immediate mitigation (owner action, not applied):** raise the auth service's max instances. Fastest: +`gcloud run services update orca-cloud-auth --region us-central1 --max-instances 20` (or `10`, matching +the other apps' `max_instances = 10`), then land the same in `auth_max_instances` so Terraform does not +revert it. Auth is stateless behind Cloud SQL (`refresh_tokens` table); backends 210 of 400, so 20 +instances x a small pool is within budget. Also consider the desktop's refresh backoff: it re-dials on +401 immediately with no jitter, so a 429 storm sustains itself. + +**13:51Z status: my gcloud session lost auth at ~13:45Z; all production monitoring from this session is +blind until re-authenticated (`gcloud auth login`, interactive). Last confirmed state 13:40Z: fleet 0 +controls, auth maxScale 2, 7,600 auth 429/min. All autonomous dispatch loops are stopped.** + +**17:19Z–17:21Z MITIGATION APPLIED (owner said "fix it NOW").** State at 17:19Z, four hours in: all 23 +cells at 0 controls, auth 429 ~2,000/min, auth 2xx ~40/min, and the 2xx that got through took 13–28 s +(both instances saturated). Mutation 1: `gcloud run services update orca-cloud-auth --max-instances 20` +created revision `orca-cloud-auth-00018-4jc` (same image `auth@sha256:1710ff6c`, same env/concurrency, +only maxScale 2 -> 20) but the service pins traffic to `00023-qud` **by revision name**, so the new revision +was immediately `Retired` and nothing changed. Mutation 2 (17:21:30Z): `gcloud run services update-traffic +--to-revisions orca-cloud-auth-00018-4jc=100`. Lesson: the auth service's traffic block is name-pinned +(the deploy workflow does an explicit traffic switch), so a bare `services update` never reaches users. +Terraform still says `auth_max_instances = 2`; the next `deploy-auth-production.yml` run will revert this +unless the tfvars and the workflow's `AUTH_MAX_INSTANCES` are changed first. + +## Finding 13 (2026-09-04 17:19Z–18:10Z): **the auth outage is a database problem, not (only) a Cloud Run cap; `refresh_tokens` has 63 M rows and reuse-revokes scan whole families** + +Mutations this window (all online, no restarts, all by hand in project onorca-cloud): +1. 17:19Z `gcloud run services update orca-cloud-auth --max-instances 20` → new revision `00018-4jc`, but traffic is + pinned by revision name so it was `Retired`; 17:21:30Z `update-traffic --to-revisions 00018-4jc=100`. +2. Still 2 instances at 17:31Z: the SERVICE has its own `scaling.maxInstanceCount=2` in **manual scaling mode** + (`run.googleapis.com/maxScale: '2'` on service metadata, set by Terraform `infra/terraform-apps/auth.tf`), which + overrides the revision cap. `--scaling=auto` then `--max 20` at 17:31:45Z. Instances 2→20 by 17:38Z; 429s fell + 6,000/2 min → 60/2 min at 17:36Z and controls briefly reached 11. +3. Then latency, not capacity, became the wall: every refresh took 100+ s inside Postgres (desktop client timeout + is 30 s, `CLOUD_REQUEST_TIMEOUT_MS`), so 20 instances × 80 concurrency filled again with requests nobody was + waiting for, and 429s returned (~1,500/2 min from 17:40Z). +4. 17:27Z Cloud SQL disk 62 GB → 250 GB (IOPS ceiling 1,470 → ~7,500). 18:00Z `max_wal_size` 1.5 GB → 16 GB + (the checkpoint loop: `checkpoint starting: wal` every 45–60 s since 13:06Z). +5. 18:07Z `CREATE INDEX CONCURRENTLY refresh_tokens_family_unrevoked ON refresh_tokens(family_id) WHERE + revoked_at IS NULL` (an earlier attempt with `AND rotated_at IS NULL` was wrong for the revoke predicate; its + invalid remnant `refresh_tokens_family_live` was dropped). + +Evidence: `refresh_tokens` = 63.3 M live tuples, 16 GB table + 10 GB indexes; every refresh inserts a row and +nothing ever deletes (30-day TTL rows are never pruned). Query Insights 17:33–17:39Z: `UPDATE refresh_tokens SET +revoked_at = $1 WHERE family_id = $2 AND revoked_at IS NULL` = 21,000 s of execution per 6 min, ~90–120 k rows +updated per minute; io_time 15,000 s read; pg_stat_activity 180+ backends in `IO/DataFileRead` on that statement, +200 backends total for orca_auth (20 instances × pool max 10). `session-refresh-reuse-detected` audit events per +hour: ~100 all day → 8,805 (13Z), 15,511, 19,486, 24,897, 26,935 (17Z). Mechanism: a desktop's refresh times out +client-side at 30 s, the server had already rotated the token, the desktop retries with the same token, the +server calls that reuse and revokes the family (Bitmap scan on `refresh_tokens_family` + heap filter over every +row the family ever had), then the desktop retries the dead token again, and each retry re-runs the same +full-family scan (already-revoked families short-circuit nowhere). Reuse-detected 401 also **signs the user out** +on the desktop (`isOrcaCloudAuthFailure` → `clearCloudSessionIfUnchanged`), so every user who hit this during the +outage must sign in again. + +Durable fixes (orca-cloud PR in preparation on branch `auth-revoke-only-live-tokens`): `AUTH_MAX_INSTANCES` and +`auth_max_instances` → 20; Terraform disk 250 + `max_wal_size=16384`; the partial index in the schema; an +`already-revoked` short-circuit in `rotateRefreshToken` that skips the family UPDATE and the audit insert. Still +open after that: prune `refresh_tokens` (expired or revoked rows older than N days), a server-side statement +timeout shorter than the desktop's 30 s so the client and server agree on failure, and an alert on auth 429s. + +**19:11Z RESOLVED at the database layer.** `refresh_tokens_family_unrevoked` went valid at 19:11:17Z (build +18:07–19:11, two full table scans of 2.1 M blocks under load). Within 60 s: refresh latency 100 s → 0.1 s, auth 429 +→ 0, active orca_auth backends 200 → 2, checkpoints back on the 5-min timer (`checkpoint starting: time` at 18:35, +18:41, 19:00, 19:11). Director `/v1/assign` returning 200. Fleet controls 0 → 17 by 19:14Z. + +**Residual: mass sign-out.** 19:11–19:14Z: 3,857 refresh 401s from 3,829 distinct IPs, then near zero. Every one is +a desktop whose family was revoked by reuse-detection during the outage; the desktop clears its cloud session on +401 (`clearCloudSessionIfUnchanged`) and stops retrying. Those users must sign in again before the relay sees +them. Fresh `/session` sign-ins: 1, 5, 3 per minute at 19:10–19:12. Recovery of controls is now paced by users +signing in, not by infrastructure. Total `session-refresh-reuse-detected` events 13:00–19:00Z ≈ 100k, against a +~100/hour baseline. +**Affected-user count (19:22Z, from `refresh_tokens`):** 23,318 live token families revoked in the window, +**21,605 distinct users**. Only ~3,800 desktops had seen their 401 by 19:15Z; the rest were closed or asleep +and will find themselves signed out on next launch, so sign-ins will trickle for days. + +**Desktop UX finding (owner's own Mac, 19:22Z):** a revoked desktop keeps showing the account card as +"Connected" and the pairing pane as "Orca Relay: Unavailable" / `relay_control_not_active` indefinitely; the +local trace writes no relay events. Only quit + relaunch surfaced the sign-out prompt, after which sign-in → +relay-token → `/v1/assign` 200 (0.15 s) → working pairing, all within 10 s. Follow-ups: the relay coordinator's +401 path should flip the account card to reconnect-required immediately, and the pairing error should say "Sign +in again to use Relay" when the cause is an auth failure. Announcement wording: "If Relay shows Unavailable, quit +and reopen Orca, then sign in when prompted." + +orca-cloud PR #474 (branch `auth-revoke-only-live-tokens`): caps → 20, disk 250 / max_wal_size 16384 in +Terraform, partial index in the schema, `already-revoked` short-circuit. Do not deploy auth to any environment +with a large `refresh_tokens` before building the index concurrently there. + +**Wave 1 of the roadmap (2026-09-04 21:35Z onward):** five Opus agents in isolated worktrees: 3.1 grace window +(orca-cloud), 4.1+2.3 relay locks + pool timeout, 3.2+4.3 desktop refresh/jitter, 5.1+5.4 observability, +2.1 private IP (plan only, both repos). First back: stablyai/orca PR #18717 (crash alert + dashboard). Its key +finding: cell exits log to `cos_system` with uppercase `jsonPayload.MESSAGE` and `SYSLOG_IDENTIFIER=docker`, +so every earlier `jsonPayload.message:"container die"` count in this doc that read 0 was querying the wrong +field. Verified: 87 exits 12–13Z on the agent's filter, 0 in the last 6 h. Monitor dry-run 33922255205 +dispatched 21:41Z as the Roll 1 gate. +Dry-run 33922255205 froze at 21:46Z on `signal_missing cloud_sql.backends`. Cause: Cloud Monitoring published +no `num_backends` point for the auth instance between 21:40 and 21:46 (every other minute of the last 100 has +one; measured directly via the timeSeries API). A Google-side publish gap, not a database or monitor defect; +the monitor's freeze-on-missing rule is correct. The 12–13Z monitor failures were a different cause (active +probes reading 0 during the crash cascade). Re-dispatched at 21:50Z. +Dry-run #2 (33922844671) froze at 21:52:21Z on `auth.health observed 0` — verdict read from the state.json +artifact, not the log (the log only prints checkpoints). Auth served `/health` 200 continuously, including the +21:52:05 probe. Cause: the probe requires `/health` AND `/ready` on the first attempt; auth has no `/ready` +(404 by design), so every auth sample takes the forced 11 s retry, and on the third sample the retry fetch threw +at the network layer on the runner (no request reached Cloud Run) and `check()` recorded the exception as +health=false. Neither freeze was fleet health. Fix delegated (relay-ops: a thrown fetch is not a reading; auth +does not require `/ready`). **Sequencing constraint for Roll 1:** monitor evidence must be < 5 min old at +canary dispatch, so the owner's go must precede the dry-run, and a green dry-run must be followed by the +canary dispatch immediately. + +stablyai/orca PR #18719 (3.2 + 4.3, desktop): the replay engine was not the refresh function but +`RelayAuthCoordinator.scheduleRetry`, since `shouldRetryRelayConnectionError` treats any non-HTTP error +(including a refresh `TimeoutError`) as retryable and re-reads the same stored token on backoff. Fix: refresh +gets one 60 s attempt; an ambiguous failure (no status line) records the token and blocks re-sending it for +30 s (bounded, not permanent); definitive 5xx gets exactly one retry after re-reading the store; a 401 on an +ambiguously-attempted token logs `orca_cloud_refresh_possible_replay`. Lease renewal gets ±10 % full jitter +(base shrunk so the latest sample stays ≥ 90 s before expiry); server resets the full 55-min TTL on any rebind +(`host-session-registry.ts:736-743`) so early renewal is free. Verified the retry-path claim and both server +cites against main. + +2.1 private IP: orca-cloud PR #477 (foundation: servicenetworking API, /24 peering range 10.42.128.0, private +network on the instance, `prevent_destroy`; real production plan 3 add / 1 in-place change, staging unchanged) +and stablyai/orca PR #18720 (relay: `relay_cloud_sql_private_ip` variable, conditional `--private-ip` in the +cell startup template; default false renders byte-identical to main). Findings that change the plan: Google +states the private-IP change **restarts the instance** with no in-place path, and it is a one-way door (cannot +disable private IP or remove the network link). The director uses the Cloud Run built-in connector, not the +relay VPC NAT, so it never consumed the exhausted ports and is out of scope. Disabling public IP later breaks +the local proxy workflow and the director. #18720 merges (inert); #477 held for owner decision. + +4.1 + 2.3 relay: stablyai/orca PR #18722. Premise correction: #18521 and #18606 had already bounded and +narrowed most of the fleet-wide lock before today; what remained were the sticky-refresh retry (all 23 rows → +the one pinned row), reservation reconciliation (23 → the 2 involved rows), a dead pool-default fallback, and +an absolute counter write (→ delta with capacity guard). Placement (`assignOnce`) deliberately keeps the +ordered inventory lock: least-loaded selection is fleet-wide and dynamic target-only locking previously caused +cross-cell cycles; converting it to optimistic snapshot + conditional delta is the remaining 55P03 floor and a +follow-up. Pool `statement_timeout` was already 5 s but hardcoded; now env-configurable, `57014` added to the +retryable set (it was terminal before), schema DDL on an untimed max:1 pool. Independently re-ran the new and +adjacent suites here against 55440: 66/66. Harness note: 55440 is not idempotent across full runs (2 +pre-existing failures on a second run); reset the schema between runs. Rollout: director first, watch +`orca_relay_postgres_transaction_exhausted` and `cellInventoryHoldMsP95` before cells. + +#18719 first CI run failed only on `windows-host-job.win32.test.ts` (EPERM on temp-dir cleanup), a Windows +PTY test the PR does not touch and which no other recent run failed on; rerun dispatched rather than waved. + +3.1 grace window: orca-cloud PR #478 merged (not yet deployed; deploy is an owner gate because the startup +schema apply adds a nullable column to `refresh_tokens` with a brief ACCESS EXCLUSIVE). Semantics: within +`ORCA_CLOUD_REFRESH_ROTATION_GRACE_MS` (60 s default, 300 s cap, 0 = off) a re-presented rotated token gets the +SAME successor refresh token + a fresh access token, no revoke, no audit, provided the successor is still the +live head. Third presentation / outside window / revoked family: unchanged (revoke + audit). Successor plaintext +is stored sealed (AES-256-GCM, key = HKDF of the predecessor token; the DB never holds the key). Cost stated +plainly: a stolen token replayed inside 60 s is served once instead of tripping detection; DB-read + stolen +predecessor recovers the successor offline until pruned. Rotation now runs in one transaction (proved by a +forced-INSERT-failure rollback test; the 8-way race alone did not kill the non-transactional mutant). Verified +locally 27/27 incl. the Postgres suite against 55440, and CI ran it on PG 16 and 17 (4/4 each, not skipped). +Deploy wiring: env is set by BOTH Terraform and the deploy workflow, with a test pinning all three sources to +one value. **Pre-existing bug surfaced:** the deploy script strips every env var it does not own, so the +Terraform-set `ORCA_CLOUD_REFRESH_TOKEN_TTL_DAYS` (from #476) silently reverts to the compiled default on each +release. Latent only because both defaults are 30. Follow-up: add it to `authEnvironment` + the workflow env. + +Monitor probe fix: stablyai/orca PR #18723. A thrown fetch (DNS/TCP/TLS/8 s abort) is now "no reading" and is +re-asked once after 1 s; only a second throw is `false`. A non-ok HTTP answer is still `false` with no extra +retry. `latencyMs` is the slowest answering round trip, never a sleep. `requiresReady` is per endpoint: auth +(no `/ready` by design) is judged on `/health` + latency; director and cells unchanged. No threshold or rule +touched; `auth.ready` had no consumer. 81/81 relay-ops tests and 9/9 evidence-script tests locally. The monitor +runs at `main` head, so once merged the next dry-run uses it. + +Applying #18717 (22:10Z): the cell-exit log metric `orca_relay_cell_process_exit` is created; the alert policy +raced descriptor propagation (404) and is being retried. **Not applied, deliberately:** the dashboard. Its +targeted plan drags in `google_logging_metric.relay_snapshot[*]`, and that plan is `32 to add, 21 to destroy`: +the Terraform source adds a `region` label to every runtime metric (`EXTRACT(jsonPayload.region)`) which the +live metrics do not have, and a label change on a log metric is a delete+create. Replacing 21 live metrics +resets their history and would blank the 14 existing relay alert policies during the swap. That is +pre-existing drift in the relay root (unapplied since the region work), not something #18717 introduced. It +needs its own reviewed apply in a quiet window, ideally with the runtime-metric replacement acknowledged as +intentional. Dashboard apply waits on that. + +**Wave 1 closed 22:20Z.** Merged: orca-cloud #478 (grace window); stablyai/orca #18717 (crash alert + +dashboard TF), #18719 (desktop no-replay + jitter), #18720 (private-IP flag, off), #18722 (relay per-cell +locks + pool timeout), #18723 (monitor probe fix). Applied to production: cell-exit log metric + alert policy. +Held for owner: orca-cloud #477 private IP (restart, one-way); the dashboard apply (behind the runtime-metric +label drift); the auth deploy carrying #478; Roll 1. Every wave-1 code change now sits on main un-deployed: +the next relay image build carries #18722 + #18723's monitor runs at main head already; the next auth deploy +carries #478. + +**Landing (2026-09-04 20:50Z–21:02Z, owner: "if you are confident the cloud changes are valid, you can land them"):** + +- Merged: orca-cloud #474, #475, #476; stablyai/orca #18693, #18694, #18698. Neither repo has branch + protection or environment reviewers; `verify` / `cloud-verify` green on main after each. +- Applied to production by targeted saved plans (each plan asserted create-only / exact-attribute before + apply, via `terraform show -json`): 4 relay resources (WAL-checkpoint log metric + 3 alert policies), 8 auth + resources (3 log metrics, propagation sleep, 4 alert policies), and the us-central1 NAT + (`enable_dynamic_port_allocation` false→true, ports 64..4096). Google's docs: switching to dynamic does not + break existing connections when max ≥ 1024 and max ≥ old min; only lowering max or reverting to static is + disruptive. asia-east2 NAT deliberately left for after a US soak. +- Not applied: the untargeted apps-root plan also carries 4 unrelated drifts (`ORCA_CLOUD_REFRESH_TOKEN_TTL_DAYS` + env on the auth service from #476, a skill log exclusion filter change, skill pressure threshold 16→8, an + artifacts bucket lifecycle rule) and fails on the 1Password Cloudflare data source locally. The foundation + root plans clean (disk 250 / max_wal_size already match). Those drifts belong to whoever runs the next full + apps apply in CI. +- `deploy-auth-production` on main 8034955 (run 33919143723) **succeeded 21:04Z**: serving revision + `orca-cloud-auth-00031-tox` at 100%, previous `00018-4jc`, cap 20, smoke passed on both URLs. First 15 min on + the new revision: 31×200 / 1×401 on `/refresh`, max latency 56 ms, no 5xx. The new + `refresh_token_prune_cursor` table exists, so the new schema applied. +- US NAT soak (21:01–21:06Z): 0 drops, 0 proxy dial errors, 0 cell exits, port_usage 11, sqlMax ~1.07 s. + Asia NAT then applied 21:05:28Z from the pre-verified saved plan (same three attributes). The deploy script strips env vars it does not own, so the Terraform + TTL var will not be on the new revision until the full apps apply lands; the auth code defaults to 30 d. +- Terraform locally needs `GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)"`; ADC is stale. + +**Alerting + NAT follow-ups (19:58Z, superseded by the landing block above):** + +- stablyai/orca PR #18693 (`relay-nat-ports-and-sql-alerts`): both relay NATs switch to dynamic port + allocation (64–4096 per VM); new relay-channel alerts for the Cloud SQL WAL checkpoint loop (log metric on + `checkpoint starting: wal`, > 3 per 5 min), Cloud SQL disk > 70%, and NAT `OUT_OF_RESOURCES` drops. No + existing workflow applies these resources; the PR body carries the targeted plan. +- orca-cloud PR #475 (`auth-observability-alerts`): log metrics + policies for auth refresh 401 (> 100 per 5 + min; Sep 3 baseline 20–80 per hour), 429 (> 20 per 5 min; baseline 0), 5xx (> 10 per 5 min), and Cloud Run + p99 latency > 10 s. Production routes to the relay Slack channel. +- Desktop stale auth-status fix: stablyai/orca PR #18694 (`desktop-cloud-session-revoked-status`). Main pushes + an auth-status-changed IPC when a 401 clears the session; panes re-fetch on mount; the pairing notice says + "Your Orca account session expired. Sign in again to use Orca Relay" and hides Retry. StrictMode regression + test verified red on the old guard. Does not help desktops already revoked today (session cleared before + this code); it fixes every future revocation. +- orca-cloud PR #476 (`auth-refresh-token-pruning`): batched `refresh_tokens` pruner as a scheduled Cloud Run + job (revoked rows kept 30 d, rotated rows 60 d against a 30 d TTL, 5k-row batches, 200 ms pauses, persisted + cursor, per-run budget) plus a 10 s `statement_timeout` on the auth request pool with schema DDL on an + untimed connection. Merges cleanly onto #474 and does not need its index (walks the primary key; + EXPLAIN-asserted no seq scan). CI ran the Postgres integration tests for real on PG 16 and 17. Ships + `auth_token_pruner_enabled = false` in both environments: enabling needs an image digest from a build that + contains the new entrypoint. Operating rules once enabled: monitor the run summary's `stopReason` and + `deletedRows`, not the exit code (a run that only ever times out exits 0); ~48 M rows drain in ~10 days at + 200k/hour; deleting them leaves dead tuples, so the 16 GB is not reclaimed without a separate VACUUM FULL or + pg_repack pass, which is its own change. +- Phone-side copy when the desktop is signed out: stablyai/orca PR #18698 (`phone-desktop-signed-out-reason`). + Real path traced: the director resolves the phone to the host's last cell (durable assignment row), and the + cell's `acceptClient` rejects with 4404. The only additive slot every shipped peer tolerates is the WebSocket + close *reason* (relay-hello and resolve schemas are zod strict; a new close code drops old phones off the + host-offline cadence). Desktop closes its control with reason `signed-out` only when the cloud session is gone + (null context after a 401, or explicit sign-out); quit and relaunch stay reasonless. Cell remembers it per + host for the dormant-assignment TTL, forgets on re-auth, and echoes it as the 4404 close reason; phone + renders "Desktop signed out — sign in to Orca on your desktop to reconnect" with the same retry cadence. + Old×new matrix in the PR body; nothing changes for any old peer. Merges cleanly with #18694. + +## What actually blocks the roll now (12:58Z summary for the owner) + +0. **Cloud NAT ports** (Finding 11, found 12:55Z): every us-central1 cell reaches Cloud SQL's public IP + through a NAT with the default 64 ports/VM; port_usage pinned at 64 and 1,514 dropped SYNs to + Cloud SQL:3307 in one 4-min window. This is the 2 s connect stall that kills old-image cells and is + still active after the disk loop broke. Fix: `min_ports_per_vm = 1024` (or dynamic allocation) on + `google_compute_router_nat.relay_gce` in `cloud/infra/terraform/relay-gce-foundation.tf`, targeted + apply; durable fix is a private IP on the Cloud SQL instance. Online, no VM restart. +1. **Cloud SQL disk** (Finding 10): 49 GB PD-SSD saturated since 11:58Z, checkpoint loop, fleet-wide + 4–6 s stalls every ~45 s. Fix: bigger disk and/or `max_wal_size`. Owner: `stablyai/orca-cloud` + `infra/terraform-foundation/database.tf` `google_sql_database_instance.auth` (no `disk_size`, + `disk_autoresize`, or `database_flags` set today, so Terraform is at defaults: 10 GB initial, autoresize + grew it to 49 GB). Add `disk_size = 200` (+ `disk_autoresize = true`) and optionally + `database_flags { name = "max_wal_size" value = "4096" }`; production tfvars are + `infra/terraform-foundation/environments/production.tfvars`; applied by `deploy-production.yml` in + that repo. Online, no restart for disk; `max_wal_size` is also a non-restart flag. Note Terraform + `disk_size` below the live 49 GB would be a destructive shrink, so 200 is safe and 49 is the floor. **This is now the first thing to do**; nothing else can pass a + 15-min gate while it persists, and it is also what is killing the old-image cells several times an hour. +2. **Old cell image** (Finding 6): dies on every stall. Fixed by rolling 519f4914 (canary inputs ready). +3. **Gate policy**: `directorErrors: 0` and per-cell health probes freeze on any single stall. Recalibrate + after 1 and 2, or bypass by hand for the canary. + +## Plan agreed with the owner (2026-09-04 ~06:45Z), in execution order + +Owner: "feel free to improve operations to make things more effective ... continue driving everything e2e +until this process is complete." Owner has had multi-day experiences with cell rolls and does not want a +9-hour sequential roll. + +1. **Lock-removal PR** (root cause). *Status 08:55Z: pushed as branch `relay-single-row-reservation` + (2 commits). Opus adversarial review found one real defect: `acquireActivity` moving a client-chosen + activity id across cells locked the old cell's row before the new one, cycling with placement's + ascending inventory lock (reviewer reproduced it as paired 55P03s on real Postgres; no 40P01 because + lock_timeout == deadlock_timeout == 1 s). Fixed with `lockCellRows` (ordered, 500 ms bound); census now + fails on any inline `relay_cells FOR UPDATE` outside the named helpers. Three-cell Postgres test moves + an activity high->low while the target row is held; 5/5 revert-mutants fail it. 480 SQLite tests + + tsc green. Also fixed a pre-existing test leak (`relay_cell_connection_snapshots`) that made + `assignment-control-supersession-postgres` fail on reruns. Reviewer re-verified 65569be3de: cycle + repro completes in 7 ms (was 1022 ms + paired 55P03); no remaining out-of-order pair in the store; + flagged two evasions in the new census guard, closed in the third commit (whole-statement scan, + covers query() too, mutation-checked with both evasions). Headroom Postgres test's one failure is + pre-existing on main (verified by swapping in main's store).* Make `activateControl` superseded-control cleanup, `acquireActivity` + existing-lease branch, and `changeActivity` use the existing single-row + `adjustCellReservationAtomically` instead of the 23-row `lockCellInventory`. Keep the global lock only + for placement (`resolve`/assignment) and sweeps. Real-Postgres contention test on port 55440. +2. **Faster same-cap rollout workflow.** (a) paced drain instead of `graceMs: 0` so a cell's ~800 hosts + re-dial over minutes, not one second (director cap is 5 x 80 = 400 in-flight); (b) cells in a batch run + in parallel once drains are paced; (c) post-canary batches use a short freshness check instead of a new + 15-min dry-run, since the in-job safety recheck already runs before each drain; (d) job timeout > 75 min. + Target: 22 cells in ~6 batches x ~25 min. +3. **Build image** with (1) merged, then one roll of the fleet with (2). Asia cells c27/c28/c29 first. +4. Re-tighten the monitor retries bar; recalibrate the Terraform exhausted alert. +5. Consider deleting the 55-min control lease rebind entirely (no recorded reason; liveness is the 75 s + watchdog + 90 s activity lease). Separate PR after (1) so its effect is measurable. + +## Faster same-cap rollout: design (step 2 of the plan), from reading the real limits + +What actually bounds parallelism today (measured on the c7 canary, run 33843071283): + +| step | c7 duration | bound by | +|---|---|---| +| prechecks (recheck, backend init, resolve, verify) | 43 s | none | +| isolate + drain + transition wait | 7 min | drain is `graceMs: 0`; `verify-relay-capacity-transition --activity restart-safe` polls until leases drain | +| Terraform template + MIG recreate + wait-until stable | 8 min | GCE recreate; per cell, independent | +| verify new incarnation + trust proof + restore | 1.5 min | none | + +Real constraints: (1) the director is 5 x 80 = 400 in-flight `/v1/assign`; a `graceMs: 0` drain of ~800 +hosts pins it at cap for ~2 min (observed 79.75/84.75 p99). (2) `production-cloud-sql-rollout` lease and +workflow concurrency group serialise the whole run, by design, and the per-cell job shares it via +`holder-key`. Nothing else forbids parallel cells. + +Changes, smallest first: +1. **Paced drain.** `HostSessionRegistry.drain(graceMs)` already sends `drain {graceMs}` and closes each + session after `graceMs`, but the desktop's `handleDrain` re-dials immediately regardless of graceMs + (`relay-origin-pool.ts:150-162`), so graceMs only delays the *close*, not the stampede. Fix on the + cell: stagger the drain *send* across sessions over a window (e.g. 800 sessions over 120 s = ~7/s), + which needs no desktop change and works for every desktop version in the field. New admin body field + `spreadMs` (optional, default 0 keeps today's behaviour); canary script passes `spreadMs: 120000`. + Requires the cell to be on an image with the change, so it applies to batches after the first + post-lock-fix roll, not to this one. +2. **Parallel cells in a batch.** In `cloud-deploy-relay-production-same-cap.yml` make `cell_2..cell_4` + `needs: [gate]` instead of chaining, gated on the same evidence (drop the `+75 min x wave-index` + allowance, it exists only because of chaining). Each job already takes the rollout lease with the + run's `holder-key`, so they re-enter it rather than fail. With paced drains, 4 cells x ~800 hosts + over 120 s is ~27 dials/s, well under the director cap. Raise `timeout-minutes` to 90. +3. **Post-canary batches skip the 15-min dry-run.** The in-job "Recheck aggregate SQL, pool, + reconnect, migration, and selector safety" step (`pnpm incident:relay-preflight`) already runs a + live one-shot check before each drain. For `batch-apply` with a sealed `canary-run-id` from the + same commit, accept a dry-run of any age (the canary's) plus that live recheck; keep the 15-min + requirement for `canary-apply`. Change lands in `relay-monitor-evidence.mjs verify-authority` + + `relay-production-same-cap-wave.mjs` + their node:test suites. + +**Correction after reading the cell job (07:35Z):** (2) parallel cells is not a flag flip. Each cell job +asserts the exact selector generation `expected + 2 x wave-index` and exact memberships derived from +predecessors having completed (`ISOLATED_*`/`RESTORED_*` in the job, `applyExactAdmissionSelector` +compare-and-swap), and all cells share one Terraform state lock. Making that concurrent means a batch-level +isolate/restore in the gate and a rewrite of the 650-line job's expectations. That is the multi-day trap +the owner described. Deferred. + +What is cheap and removes most of the wall-clock: (3). The per-batch 15-min dry-run costs 15 min each +*and* fails ~50% of the time on old-image crashes, which is where hours go. Implement: `batch-apply` with a +verified canary authority accepts a passed dry-run up to 6 h old and may re-use one already consumed +(the consumed-marker check exists to stop replaying stale evidence; the canary binding plus the in-job +live preflight at drain time replace it). Files: `relay-monitor-evidence.mjs` (`--after-canary`), +`incident-live-preflight-cli.ts` (same flag), the same-cap workflow + job, and both test suites. +Revised expectation: 22 cells = 6 sequential batches x ~70 min = ~7 h wall-clock but *unattended-safe* +and with one dry-run total, versus today's 6 dry-runs at ~50% each. (1) paced drain rides the lock-fix +image. + +## Recommended next steps (superseded by the plan above; kept for history) + +1. Resolve the gate decision above, then: monitor dry-run -> c7 `canary-apply` only -> verify -> stop. + Each rolled cell leaves the Finding 6 crash class. +2. Merge #18565; publish; a later same-cap roll carries it to cells. +3. Remove the global inventory lock from per-connection paths (`acquireActivity` existing-lease + branch, `activateControl` superseded-control cleanup, `changeActivity`) by using the existing + `adjustCellReservationAtomically` single-row update. Own PR, after the roll. +4. Recalibrate the Terraform alert `relay_postgres_retry_exhausted` to 300/300 s (observability root). +5. Whether to raise `relayPostgresRetries` is a human call; the data is in Finding 5. + +## Canary blast radius (read before dispatching c7) + +- What `canary-apply` does to c7, in order: isolate (selector -> migration-only, no new + assignments), `/v1/admin/drain graceMs:0` (every control on c7 re-dials the director and is + reassigned), Terraform template + MIG update to the target image, wait stable, verify new + incarnation + exact digest + protocol, prove per-host trust, restore c7 to general admission. + On any failure c7 is left isolated (migration-only) with rehome disabled; nothing else is touched. +- c7 at 05:20Z: 788 controls, 5 splices, 800 connections. So ~790 desktops re-dial once. The fleet + already absorbs this exact event 201 times / 48 h uncontrolled (Finding 6); the controlled version + isolates first, so no new assignment lands on c7 mid-roll. Expect a director concurrency blip, not + a freeze-class one (six cells at once gave 85; one cell should stay well under 64). +- Precedent: the identical workflow (pre-move, in orca-cloud) ran 9 successful `apply` canaries and + batches on 2026-08-27 (last: c20 -> 5aedbca5). Its failures that day all stopped at the read-only + "Recheck aggregate SQL..." or "Require durable rehome disabled" step, before `MUTATION_STARTED`. + The moved copy in this repo has one run: the read-only `verify` of c7 (passed, including WIF auth). +- c7 side note: MIG autoheal recreated the c7 instance four times on 2026-09-01 08:02-08:42 PDT + at ~13 min spacing. Same crash class as Finding 6 (health check failing during restart loops). + +### Canary observed effect (c7 drain, 2026-09-04 06:10Z) + +- c7 807 controls -> 0 between 06:08:52Z and 06:10:52Z. Director `/v1/assign`: 200s 32 (06:09) -> 2628 (06:10) + -> 340 (06:11); 5xx 1969 (06:10) -> 31 (06:11). Director max-concurrency p99 7.9 -> 79.75 (06:10) -> 84.75 + (06:11), i.e. at the Cloud Run cap of 80 for ~2 min. My pre-dispatch estimate ("well under 64") was wrong. +- Confounder: c10 (us-central1, instance 2803000337345335589) crashed 06:09:56Z on the old-image class + (Node.js banner + container die), so ~1,600 hosts re-dialed in the same minute, not ~800. Coincidental; + the fleet has one of these every ~15 min. +- Recovery: 06:13 903 / 06:14 1471 assign 200s from 640 distinct desktop IPs; 503s 78 -> 183 -> 29/min. + No cell crash 06:12–06:16Z. Drain step passed ~06:16Z; template/MIG apply started. +- 06:16:03–06:17:08Z, during c7's template apply (not its drain): c27 (x4) and c29 (x3) crash-looped on the + old-image pg-pool connect timeout in `beginProof`, both MIGs autoheal-recreated (c27's second recreate in + 40 min). Fleet 23 -> 21 reporting cells, controls 13286 -> 12462, assign 503s 1000/min at 06:17, director + concurrency p99 74.8. Cloud SQL CPU 0.70 max, backends 174 max (bar 250). Same multi-cell pattern occurred + at 01:31Z (4 cells) and 04:47Z (5 cells) with nothing rolling; the c7 drain's SQL load 6 min earlier may + have nudged the pool timeouts but the class is pre-existing. c7 MIG RECREATING onto new template + `…20260904061618…` = the expected image swap. +- 06:20Z: 849 assign 503s. Closes 06:19:30–06:21: 162x1006 age<5min (hosts bouncing off the recreating + c27/c29), 73x4408 + 53x1006 in the 50-min age bin (Finding 3 rotation cohort). Not roll-caused. + c7 MIG `recreating=1` on the new template since 06:16:18Z; c27 and c29 MIGs also RECREATING (autoheal). +- 06:23:16Z c7 instance restarted in place (MIG RECREATE keeps name/id relay-c7-bwjc / 4545742188814054238), + pulled `relay@sha256:85bf6799…` 06:23:37Z, listening + readiness true 06:23:42Z. Apply step passed 06:24Z; + verify step running. Isolate -> ready on new image took ~14 min end to end. +- Post-restore c7 on new image (06:25:42–06:26:42Z): controls 143 -> 273 -> 377 refilling, sqlQueries + ~1,500/30 s, `sqlLatencyMsMax` 518 -> 1003 -> 1155 ms, still 55P03 `cell-inventory` retries. So the new + image alone does not remove lock waits; the request-path 500 ms cap from #18521 applies to the director's + paths, and cell-side `acquireActivity`/`activateControl` still ride the global lock (step 3 in next steps). + Watch: does c7's sqlLatencyMsMax settle below the old 1.0–1.2 s pin once refill finishes, and does c7 stop + appearing in `container die` (the real win: guardSessionTask). +- 08:25Z (2 h after restore): c7 817 controls, 0 crashes since 06:25Z. Fleet crashes last 2 h: c27 x6, + c28 x5, all old-image Asia cells. The new image stops the crash class as predicted; it does not move + lock latency (c7 sqlLatencyMsMax 1005 ms), which is #18606's job. +- Implication for the batch phase: every drain will push director concurrency past the monitor's 64 bar + for ~1-2 min. The batch job rechecks safety *before* it drains (read-only step), so that is fine per wave, + but never run a monitor dry-run concurrently with a wave, and prefer batches of 2 over 4 until the fleet + is on the new image and the crash class is gone. + +## Post-merge dispatch plan for #18606 (image -> director -> cells) + +1. `gh workflow run cloud-publish-relay-production.yml --ref main -f mode=publish` (after the squash lands + on main). Resolve the digest by tag, never by parsing the log (it mixes relay and fence-broker digests): + `gcloud artifacts docker images describe us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay:sha- --format='value(image_summary.digest)'`. +2. Director: `gh workflow run cloud-deploy-relay-production-director.yml --ref main -f image-digest= + -f regional-placement-mode=preserve -f prune-incompatible-revisions=false -f expected-rehome-generation=12 + -f bootstrap-runtime-identity=false -f predecessor-image-digest=` + (no monitor evidence needed; requires rehome disabled at gen 12, which it is). Last run 33826514754 used + the same shape. Watch director `orca_relay_postgres_transaction_retry` per minute before/after. +3. Cells: same-cap `verify` c7 with target=, rollback=85bf6799; fresh dry-run; `canary-apply` c7; + then batches (3 per batch, Asia c27/c29/c28 first). Each batch: new dry-run unless the batch-reuse + change (design section above) has shipped. + +## Finding 8 (2026-09-04 08:40Z): ten-cell crash cascade during the director deploy, not caused by it + +Timeline: candidate revision 00570-siv created 08:38:39Z, first log 08:39:20Z; traffic still 100% on +00565-fes through 08:43 (assign logs by revision). Cell crashes: c28 (5031087219978409220) looped 08:37:55– +08:40:07 (9x), then at 08:40:20–08:40:45Z **ten** instances died within 25 s (c10 2803…, 5110…, 532…, 5464…, +7536…, 7726…, 8671…, 8928…, 8966…). All old-image `beginProof` pg-pool timeouts. Fleet controls 13,423 -> +6,157 by 08:43; assign 503s 3,912 (08:42) and 4,624 (08:43) per minute, director concurrency 85 (cap 80), +Cloud Run autoscaled 5 -> 10 instances, Cloud SQL CPU 0.55 -> 0.99. Deploy finished cleanly at 08:45Z with +the new director taking the tail of the storm; by 08:46 503s were ~30/15 s, controls 7,913 and rising, +director lock retries 29/min (vs 105–157/min pre-deploy) and exhausted 2/min (vs 65/min at 08:36). +Same class as 01:31Z (4 cells) and 04:47Z (5 cells) today; this was the biggest. c7, on the new image +since 06:25Z, did not crash. What triggered the pool timeouts fleet-wide at 08:40 is not established; Cloud +SQL CPU was 0.78–0.88 in the minutes before, the highest of the day, so the cells' 2 s connect timeout is +the plausible tipping point under a busy database. Every cell still on 5aedbca5 remains exposed to this. + +## Finding 9 (2026-09-04 08:56Z): #18606 on the director cut lock retries ~10x + +`orca_relay_postgres_retries` per 5 min, director only: 08:21–08:41 windows 419–689 (old image, incl. the +crash storm); 08:46/08:51/08:56 (new image 519f4914, refilling ~7k hosts): **61 / 69 / 54**. Exhausted: +104–178 -> **11 / 14 / 12**. Inventory hold p95 ~200 ms, max 255 ms, ~366 holds/min. Cells (still old +image) 17–44 -> 0–3, because the director no longer holds the 23-row lock on their behalf. This is the +first direct measurement of the root-cause fix under real load. Cloud SQL CPU peaked 0.99 during the +cascade and is decaying (0.86 at 08:55); the monitor freezes above 0.80, so no dry-run until it clears. + +Fourth cascade 09:00:12–09:00:18Z: c23, c8, c16, c26, c22 (five cells, 11 container-die events in 6 s, +all `5aedbca5`, exitCode 1, Node banner, pg-pool `client closed the connection` burst right before). Cloud +SQL CPU 0.84 -> 0.78 in the preceding minutes, director concurrency 18–22 (idle), so this one fired +*without* a database or director spike. Fleet had just recovered to 13,015. Cadence today: 01:31 (4), +04:47 (5), 08:40 (10), 09:00 (5), 09:31 (c13, c23), 09:34 (c23 again, c14, c20, c9; c14/c20 crash-looping), +09:39 (c21, c24), 09:55 (c16, c8), 09:59 (c20), 10:05 (c8, c20), 10:19 (c16 stalled, no crash), then a 58-min +lull, 11:04 (c9; c28 died 13x in 4 min, autoheal recreate 11:09Z, its 3rd recreate today), 11:17 (c10, c28 +again, c22, c23, c14 x9 looping; 23 dies in ~90 s; fleet 13.3k -> 10.8k), 11:31 (c14, c23, c25, c15, c24, c19), 11:34 (c20, c26, c29 x4, c14, c27 x3, c25; fleet 13.1k -> 10.3k). +Three cascades in 17 min. 11:38–11:45 c27 crash-looped 17x and c28 4x (Asia cells), c29 recreating. +11:59 (c21, c9, c10, c23), 12:02 (c19; 4,109 assign 503s that minute, mostly hosts bouncing off the +recreating cells, code 1006 age<5min x217), 12:09–12:12 (c19, c27 x6, c28 x5, c13, c22, c15, c26, c14; +8 cells, c27/c28 recreating again). Cloud SQL CPU 0.62–0.85 through it. 12:20 (six more cells). Cascade +cadence since 11:00 is now ~every 8 min; the waiter has held correctly the whole time and there has been +no dispatchable window. Loop continues unattended; findings stop logging each cascade from here unless the +class changes. Every cell that has died today is on 5aedbca5; c7 (85bf6799, +5.5 h) has not. Cell dies per hour today: +01Z 5, 02Z 7, 03Z 4, 04Z 7, 05Z 4, 06Z 9, 07Z 11, 08Z 30, 09Z 26, 10Z 2, 11Z 68+ (to 11:42). +Director concurrency pinned at 85 for 09:32–09:33; 503s 4,141 and 4,396 per minute. 09:39: c21, c24 +(2,870 503s). Crashes per instance 08:10–09:40Z: c28 x14, c27 x5, c23 x5, c22 x4, c14 x4, c13/c20 x3, +then c26/c9/c24/c16/c8 x2. Mean gap between cascades since 08:40: ~12 min. Every 15-min gate attempt +now has well under even odds; the c7-style canary that ends this needs a gate it can pass. The old image is now cascading roughly hourly regardless of load; the +only cell on a fixed image (c7) has 0 crashes in 2.5 h across all four. + +Director 500s: 4 in the 09:00 window, all 2.0 s latency on `/v1/assign` or `/v1/resolve` = pg-pool connect +timeout surfacing as a 500. Pre-existing (Sep 3: 03h/08h/16h one each, same 2.0 s shape; 06:09Z today on +the old image during the c7 drain). The monitor's `directorErrors: 0` bar freezes on any of these, so a +dry-run needs a 15-min window with none; at ~1 per cascade that is a real but modest constraint. + +**Gate observation (09:26Z):** `directorErrors: 0` counts every non-503 5xx on the director, including +the monitor's own admin calls. The director on 519f4914 still sees an occasional 2.0 s pg-pool connect +timeout (~1 per 20 min under today's Cloud SQL load), which surfaces as a 500 on whichever request drew +it. Two consecutive dry-runs (#7, #8) froze on exactly this: one, isolated, 2 s 500. That bar was set for +"unexpected director 5xx"; a single connect timeout that the client retries is not an incident. Candidate +recalibration (own PR, not done): `directorErrors` 0 -> 2 per 5 min, or exclude the monitor's own +user-agent. Not changing it unasked; noting that at ~3 per hour the 15-min gate passes ~1 in 2 attempts. + +**Did the director deploy make cells crash more? (checked 09:45Z)** Cell `container die` per 30 min: +06:00 9, 07:00 2, 07:30 9, **08:30 30** (director candidate 08:38, traffic 08:43–08:45; the 10-cell burst +was 08:40:20, before the move), 09:00 11, 09:30 12. Per hour today 05:4 06:9 07:11 08:30 09:23 vs Sep 3 +same hours 2/7/8. So today is 2–3x worse than yesterday and was rising before the deploy; after the deploy +it is ~11–12 per 30 min, in line with 06:00–07:30. Cloud SQL backends (~230 max) and new connections +(~5k/30 min) are flat across the deploy. Latest crash (c21 09:39:11) is `Connection terminated due to +connection timeout` with cause `Connection terminated unexpectedly` in `verifyCellAssignment` <- +`beginProof`, the same unhandled path. Conclusion: no evidence the deploy worsened it; the old image's +crash rate simply climbed all day. Director lock retries stayed ~10x lower after the deploy. + +**Checkpoint-phase check (10:00Z, negative result):** Postgres checkpoints complete every 5 min at ~:07. +Cell crashes bucketed by phase within that 5-min cycle show a mild :00–:29 s cluster today (22 of 103) +that is absent on Sep 3 (7 of 114), so checkpoints are not the trigger. Disk write bytes in cascade +minutes are at or below the median except 09:00. Cloud SQL memory 0.47, transaction rate flat. The +09:55 stall (11 director + 4 cell pg-connect timeouts in the same 4 s) came with `could not obtain lock +on row in relation "relay_cells"` from a NOWAIT sweep at 09:55:36, i.e. someone was holding the full +inventory at that moment. On the new director that can only be placement or a sweep; on the old cells it +is still every rebind. What stalls *connections* (not locks) for 2 s fleet-wide remains unexplained; +Cloud SQL is `db-custom-4-15360` REGIONAL PD_SSD 49 GB at 0.5–0.75 CPU when it happens. + +**Stall census (10:01Z):** 33 pg-connect-timeout stall events today (clusters of timeouts < 20 s apart). +Before 08:35 they were 1–9 timeouts each and 10–60 min apart; from 08:35 the big ones are 16, 22, 21, +17 timeouts and 5–30 min apart. No second-of-minute phase (start seconds spread across all buckets), so +not a fixed timer. Cloud SQL backends by state at 09:55: active peaked 42 at 09:52, idle-in-transaction +≤ 10, nothing near the 400 ceiling; memory 0.47; disk normal. Each stall is a few seconds where *new* +connections to Cloud SQL (via the auth proxy socket) time out at the 2 s `connectionTimeoutMillis`, +hitting every process that happens to need a fresh pool connection in that window. Old-image cells die +on it (unhandled), new-image director logs a 2 s 500 and continues. Root cause of the stall itself is +outside the relay code (Cloud SQL proxy or instance); not chased further here. + +## Finding 10 (2026-09-04 12:40Z): Cloud SQL disk write saturation since 11:58Z is driving the stalls + +`orca-cloud-auth-db` is `db-custom-4-15360` on a **49 GB PD-SSD** (81% used). PD-SSD performance scales +with size: 49 GB gives roughly 1,470 write IOPS and ~23 MB/s write throughput. Measured: + +| | before 11:58Z | 11:59Z onward | +|---|---|---| +| disk write MB/s | 4–6 | **30–50** (over the ~23 MB/s cap) | +| disk write IOPS | 500–800 | 800–1,475 (at the ~1,470 cap in 11:59, 12:15, 12:24, 12:34) | +| checkpoint `sync=` | 0.07–0.2 s (Sep 3 max 0.65 s, 290 checkpoints) | 2–20 s; 27 of 39 checkpoints in 12Z were >= 2 s | +| checkpoints per hour | 12 (timed, every 5 min) | 39 (WAL-triggered, every ~45 s; `write=` fell from 270 s to 30 s) | +| Cloud SQL CPU / memory | 0.5–0.8 / 0.47 | same (not the bottleneck) | + +Every 4 s+ fleet-wide SQL stall since 11:04 (11:04, 11:17, 11:31, 11:34, 12:09, 12:10, 12:18, 12:20, +12:30) sits inside a slow checkpoint `sync` window; the 12:30:49 checkpoint synced 5.88 s (longest file +5.47 s), matching the 12:30:02–41 stall. During fsync the WAL writer stalls and every session waits, which +is why the stall hit all 23 cells and the director at once regardless of the relay lock changes. The +old-image cells then die on the pool timeout; the new image survives. What raised write volume ~8x at +11:58Z is not established (autovacuum ran on every relay table 11:55–11:57 and checkpoints are being +forced by WAL volume, so a write amplifier inside Postgres is the leading candidate; relay transaction +rate and Cloud SQL network bytes were flat). This is the first cause found today that is *upstream* of +the relay code and it explains the afternoon acceleration (11Z 68 dies, 12Z 47 by 12:34). + +Corrections after digging (12:45Z): relay query volume, renewals, reconnects, and assignments per 5 min +were **flat** across 11:58 (sqlQ ~330k, renewals ~115k), so the relay did not start writing more. WAL +recycling per checkpoint went 7 -> 10–11 files (16 MB each) at 45 s intervals, i.e. WAL output rose from +~0.4 MB/s to ~4 MB/s while data-file writes rose to 30–50 MB/s; checkpoints switched from `time` to `wal` +triggered at 11:58:24. No Postgres slow-statement or "checkpoints too frequently" lines. This is +write amplification inside Postgres (full-page writes after each of the now-frequent checkpoints on +hot pages, plus autovacuum on every relay table each minute) on a disk too small for its IOPS ceiling, +not new relay load. Instance label `managed_by=terraform`, created 2026-07-09; the instance resource is +**not** in `cloud/infra/terraform` (only the database, user, and secret are, via +`local.relay_database_instance_name`), so it lives in the other Terraform root (orca-cloud, per +[[orca-cloud-terraform-split-findings]]). `storageAutoResize=true` with limit 0, so Cloud SQL will grow +the disk only when it fills, not when IOPS saturate; disk is 81% full. + +Onset precisely: the 11:55:37 `time` checkpoint wrote 67,258 buffers (10.5% of shared_buffers, the +day's largest) over 163 s and completed 11:58:24. Every checkpoint since has been `wal`-triggered at +~45 s spacing (`max_wal_size` reached), each writing 13–20k buffers with 9–11 WAL files recycled. This is a +self-sustaining loop: a checkpoint completes -> every subsequent write to a hot page emits a full-page +image into WAL -> WAL fills `max_wal_size` in ~45 s -> next checkpoint -> repeat. The relay's hot rows +(`relay_cells`, `relay_assignments`, activity leases, cell runtime) are updated tens of thousands of +times a minute, so full-page-write amplification is large. Before 11:58 the 5-min timed checkpoints kept +WAL well under the limit; a one-off larger checkpoint tipped it over and the disk's write ceiling keeps +it there. Query Insights: io_time +30% in the 12:00 bucket, lock_time flat. + +**Owning workflow / mitigation (not applied):** raise the Cloud SQL data disk (PD-SSD IOPS and MB/s scale +linearly with GB; 49 -> 200 GB roughly quadruples the ceiling, online, no restart) in the Terraform root +that owns `google_sql_database_instance` for `orca-cloud-auth-db`, applied through that root's workflow. +A second, flag-level lever is raising `max_wal_size` (default 1 GB) so timed checkpoints resume; that is +also a Cloud SQL instance setting in the owning Terraform root. Per the standing rule, not applied from +this session. Until then the fleet-wide 4–6 s stalls recur on +every slow checkpoint sync, the old-image cells die on each one, and no 15-min gate window will exist. + +## Finding 11 (2026-09-04 12:55Z): **Cloud NAT port exhaustion** on the us-central1 cells is the second stall class + +`google_compute_router_nat.relay_gce` (us-central1, `AUTO_ONLY` IPs, no `min_ports_per_vm`, no dynamic +port allocation, i.e. the default **64 ports per VM**). `router.googleapis.com/nat/port_usage` per VM +hit **64 = the cap** in exactly the minutes the cells' Cloud SQL proxies logged `dial tcp +35.188.82.89:3307: i/o timeout` (12:20–12:22, 12:41–12:43, 12:51–12:53), and +`nat/dropped_sent_packets_count` went 0 -> 56/552/590, 82/272/133, 395/1565/1842 in those same minutes. +Hourly: port_usage max was 25–50 all of Sep 3 and until 10Z today, 64 in 11Z and 12Z; dropped packets 0 +until 11Z (219), then 5,491 in 12Z. Open NAT connections rose 400–600 -> 815–874. Every cell's Cloud SQL +traffic egresses through this NAT to the instance's public IP (the instance has no private IP: +`ipv4Enabled=true`, `privateNetwork` unset). When a VM's 64 ports fill, new TCP SYNs to 3307 are dropped, +the proxy's dial times out, and the relay pool's 2 s `connectionTimeoutMillis` fires: that is the exact +2 s stall the old image dies on and the new director surfaces as a 500. The dial timeouts hit c7 and c8 +hardest because they carry the most controls and open the most DB connections. + +What raised port demand today: each old-image crash re-opens a full pool through fresh NAT ports, the +autoheal recreates do the same, and the 55P03 retry storms keep more connections mid-transaction, so +crashes and NAT exhaustion feed each other. This is why the afternoon accelerated even after the disk +loop broke at 12:39. + +**Owning change (not applied):** `cloud/infra/terraform/relay-gce-foundation.tf` +`google_compute_router_nat.relay_gce` (this repo): set `min_ports_per_vm = 1024` (or enable +`enable_dynamic_port_allocation = true` with `max_ports_per_vm = 4096`) and, if needed, add manual NAT IPs +(each IP supplies 64,512 ports across VMs). Online change, no VM restart. The durable fix is giving the +Cloud SQL instance a **private IP** and pointing the proxy at `--private-ip`, which takes DB traffic off +NAT entirely; that is a Cloud SQL instance change in the orca-cloud foundation root plus a startup-script +flag here. Per the standing rule, not applied from this session. + +Direct proof: `resource.type="nat_gateway" AND jsonPayload.allocation_status="DROPPED"` shows **1,514 +dropped allocations to 35.188.82.89:3307** in 12:50–12:54 alone, every one of them the Cloud SQL public +IP. The NAT has zero manual IPs (AUTO_ONLY) and no port settings in Terraform, so it is at Google's +default 64 ports/VM. No workflow in this repo applies `relay-gce-foundation.tf` broadly (the roll +workflows apply cell templates with `-target`), so the NAT change needs a targeted apply of +`google_compute_router_nat.relay_gce`, which is an owner-run Terraform step. + +Original write-up of the symptom before the NAT correlation follows. + +The 12:50:30–12:50:50 stall (every cell 3.7–3.9 s SQL max, six old-image cells died) happened with +checkpoints healthy (85 ms) and disk at 6 MB/s, so it is not Finding 10. The cells' Cloud SQL Auth Proxy +logged `failed to connect to instance: dial error: dial tcp 35.188.82.89:3307: i/o timeout`. Count of +those per hour today: 08Z 1, 11Z 15, **12Z 416**; all of Sep 3: 4. Cloud SQL `up`/backends/connections +did not blip. So new TCP connections to the instance's public IP on 3307 are timing out from the cells' +proxies in bursts, which is exactly the "2 s connect timeout" the old image dies on. Query Insights for +12:49–12:54 attributes 1,380 s of lock wait to the placement CTE (`WITH assignment_state AS +MATERIALIZED …`) and 469 s to the single-row reservation UPDATE: the lock queue is the *consequence* of +connections stalling mid-transaction, not the cause. Not chased further; candidates are the proxy's +connection churn under the crash loops (each recreated cell opens a fresh pool) and the instance's +public-IP path. Relay code cannot fix this; it is Cloud SQL / network. Dial timeouts by minute today: 12:20 24, 12:21 +66, 12:41 22, 12:42 6, 12:51 160, 12:52 137, i.e. bursts of 20–160 s each, and they hit c7 (new image, +89 today) and c8 (93) hardest, so it is not the old image's connection churn either. Cloud SQL `up`=1 +throughout. The proxy dials the instance's public IP `35.188.82.89:3307`; a burst of i/o timeouts to a +healthy instance points at the path (public-IP egress / NAT / proxy connection limits), not at Postgres. +That is the same 2 s that the old image dies on and that the new director surfaces as a 500. + +## Roll inputs (verified by the read-only `verify` run) + +**Image census from instance templates, 2026-09-04 21:45Z (authoritative, read from `gcloud compute +instance-templates`):** 20 serving cells on `5aedbca5` (c8, c9, c10, c13–c16, c19–c29) — the image that exits +the process on a Postgres connect timeout (Finding 6); c7 on `85bf6799`; c4, c5, c17, c18 (draining / +migration-only) on `0e83408b` / `36a56b10`; c1, c2, c3, c6, c11, c12 (existing-only) on Jul/Aug images. Target +for Roll 1 is `519f4914` (director already on it). Monitor dry-run dispatched 21:45Z as the roll gate; waves +require owner go. + + +- target-image-digest `sha256:519f4914217f08cabcdcd34825965db8473ec37c6591553a3af0d65dcdeeb183` (lock fix; supersedes 85bf6799 as target) +- previous target `sha256:85bf67993869a769642995d0863f4c2b6b569c3850c2d8390ec2ca5f2b179e28` (c7 is on this; use as c7's rollback) +- rollback-image-digest `sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563` +- target/rollback rehome protocol 1 / 1; expected-rehome-generation 12; selector generation **112** (110 before the c7 canary) +- existing-only c1,c11,c12,c2,c3,c4,c5,c6; migration-only c17,c18; general c10,c13–c16,c19–c29,c7,c8,c9 +- confirmation for canary: `ROLL_RELAY_SAME_CAP production-gce-c7` +- monitor evidence is single-use and must be < 5 min old at dispatch (plus 75 min per predecessor wave) +- monitor dry-run dispatch (read-only, runs at `main` head so a merged bar change applies immediately): + `gh workflow run cloud-monitor-relay-production.yml --ref main -f mode=dry-run -f expected-selector-generation=110 + -f expected-existing-only-cells= -f expected-migration-only-cells=production-gce-c17,production-gce-c18 + -f expected-general-cells= -f migration-policy=strict -f recovery-source-cell-id=none -f capacity-cell-id=none` + +## Queries that worked (copy-paste) + +- Cell metrics: `resource.type="gce_instance" AND jsonPayload.event="orca_relay_runtime_metrics"` +- Container crashes: `resource.type="gce_instance" AND jsonPayload.MESSAGE:"container die" AND jsonPayload.MESSAGE:"relay@sha256"` +- Crash banner: `resource.type="gce_instance" AND jsonPayload.message:"Node.js v24"` +- Retries: `jsonPayload.event="orca_relay_postgres_transaction_retry"` (no resource filter to get both) +- Director lines are `textPayload`; cell lines are `jsonPayload.message` +- Cloud Run concurrency: Monitoring API `run.googleapis.com/container/max_request_concurrencies` +- Dry-run final state: download artifact `relay-monitor-dry-run--`, read `*.state.json` (the log's `schemaVersion` lines are only checkpoints, not the final verdict) + +## 2026-09-04 22:50Z onward: owner go received; driving the gates + +Owner: "sure, feel free to drive these." Sequence chosen: Roll 1 first (highest uplift), auth deploy with +#478 second, pruner enable third, label drift resolved by matching Terraform to live state, #477 still held. + +| Step | Result | +| --- | --- | +| Monitor dry-run #19 (gen 112, strict) | **Passed** 23:07:53Z, run 33927238469 attempt 1. First green since the probe fix (#18723). 16 samples, no freeze. Dispatched 22:51:33Z after confirming: 0 `container die` in 3 h, director 5xx in the last 4 h were all 503s (excluded by the `director.errors` filter). | +| c8 `canary-apply` onto 519f4914 (rollback 5aedbca5) | **Failed at 23:09:07Z before any mutation**: `relay monitor evidence provenance does not match` in `verify-authority`. Run 33928330631. Gate job passed, `cell_1 / rollout` failed on the manifest check, `seal_canary` skipped, lease released. Cause: the manifest binds `commitSha`; the dry-run ran at main `264c9ed8d2`, the canary dispatched at `--ref main` resolved to `4fab8e2f15` because unrelated PRs merged to main during the 15-minute gate. Verified no side effects: c8 MIG still on template `…c8-20260827…` (5aedbca5), stable, 25 controls; no `/v1/admin/drain` or isolate calls in the director log. | +| Constraint learned | Both workflows must run at the **same main commit**. The production environment's deployment branch policy allows only `main`, and the job gates on `github.ref == 'refs/heads/main'`, so a pinned tag/branch is not an option. Any merge to stablyai/orca main during the 15-minute dry-run invalidates the evidence. Mitigation for the retry: dispatch the canary within seconds of the green, and do not merge anything to stablyai/orca main myself during the window. A durable fix (accept evidence whose commit is an ancestor with identical workflow/script content) is a follow-up, not a same-day change to a safety check. | +| Label drift (5.x) | Resolved by dropping the `region` label from Terraform to match the 21 live metrics (stablyai/orca #18734, merged). Targeted plan asserted `27 no-op, 9 create, 0 destroy`; applied 23:11Z: 8 `orca_relay_control_*` renewal metrics that had never been applied, plus `google_monitoring_dashboard.relay_incident`. `orca_relay_controls` createTime unchanged (2026-07-13), label extractors unchanged. | +| Pruner enable (1.2) | orca-cloud #479 merged: `auth_token_pruner_enabled = true`, image digest of `00031-tox`, `max_rows_per_run = 20000`. Targeted plan asserted 9 create / 0 change / 0 destroy (job, scheduler at `41 * * * *` UTC, two service accounts, five IAM grants). **Not yet applied**: waiting until the roll canary has landed so the first hourly run does not overlap a drain. | +| Auth deploy with #478 (3.1) | Dispatched 23:13Z from orca-cloud main `f0fa4b5` (run 33928663526). Candidate startup adds nullable `successor_material` under a brief ACCESS EXCLUSIVE lock. | +| Auth deploy result | **Succeeded** 23:15:37Z: `orca-cloud-auth-00035-gos` serving 100 %, cap 20 preserved, 0 5xx. `refresh_tokens.successor_material` present (nullable text); 298 sealed successors written in the first 15 min against 924 rotations; `session-refresh-reuse-detected` at baseline (5 / 15 min). Grace window is live. | +| Monitor dry-run #20 | Froze 23:35:38Z on `runtime_power_unknown cell.production-gce-c11.powered`. Two window restarts earlier (23:24, 23:25) on `signal_stale auth.errors` (Cloud Monitoring publish lag 181–255 s vs 180 s bar). Cause: one transient rejection of the per-cell MIG GET in `readResourceInventory` yields `targetSize: null` → `runtimeKnown=false` → hard freeze. c11 is a parked existing-only cell (MIG size 0, stable) and was fine. Not fleet health. Fix delegated: stablyai/orca #18740 (retry the MIG read once, mirroring #18723). Run 33928912676. | +| Monitor dry-run #21 | **Green** 23:54Z at main `8064d1f991`, but main had moved to `0a821e5bc8` during the window; the chain re-gated instead of dispatching (the canary would have failed provenance again). Run 33930229711. | +| Monitor dry-run #22 | **Green** 00:10Z at `0a821e5bc8`; main moved to `2e80972450`. Re-gated. Run 33931177390. | +| Monitor dry-run #23 | Froze 00:18:31Z on `cell.production-gce-c29.latency_ms` 2635 > 2000, the probe's own round-trip from a US runner to asia-east2; c29 controls 17→19 and `sqlLatencyMsMax` flat ~1050 through the minute, no crash, no checkpoint stall. c29 probe max was 0 in the three previous gates, so a one-off. Run 33932092775. | +| Blocking constraint | Main receives unrelated merges every 5–10 min (23:08, 23:15, 23:17, 23:40, 23:42, …). A 15-min gate bound to an exact commit cannot be consumed under that traffic. Delegated a durable fix: `verify-authority` accepts evidence whose commit is an ancestor of the canary commit **and** has no diff on the monitor/deployer trusted paths; fails closed on shallow clones or unknown commits. Chain re-armed on dry-run #24 (run 33932679796) meanwhile. | +| Monitor dry-run #24 | Froze 00:28:00Z on `director.instances` 4 < 5. Cloud Run active-instance count read 4 for exactly one minute (00:27), 5 in every other minute for 3 h; min/max scale is pinned at 5; no new revision. A routine single-instance recycle. Not fleet health. Bar `directorInstancesMin: 5` with `latest-sum` cannot tolerate that; recalibrate to 4 or use a 3-min window minimum (follow-up, not same-day). Run 33932679796. Chain dispatched #25 (run 33933193511) at `86cd327749`. | +| Monitor dry-run #25 | **Green** 00:46Z at `86cd327749`; main moved to `8096cb2803`. Fourth green gate lost to unrelated main traffic (#19, #21, #22, #25). Run 33933193511. Chain's re-gate #26 (run 33934079533) cancelled by me. | +| Fixes merged 00:55Z | stablyai/orca #18740 (MIG inventory read retried once before `runtime_power_unknown`; 2 tests) and #18754 (`verify-authority` and the batch canary authority accept evidence sealed at an **ancestor** commit when every trusted monitor/deployer path is byte-identical; fails closed on shallow clones and unknown commits; deploy/rehome jobs now check out with `fetch-depth: 0`; 5 new tests, 18/18 pass). Reviewed both diffs; trusted-path set verified to exist on main. | +| Monitor dry-run #27 | Dispatched 00:56Z at `74ad08ec66` (first gate whose evidence the new rule can consume). Run 33934541092. Chain re-armed with the same ancestor + identical-trusted-code rule so an unrelated merge no longer forces a re-gate. | +| Monitor dry-run #27 | **Green** 01:11:35Z at `74ad08ec66`; main had moved to `38bde20121` with identical trusted code, so the new rule (#18754) let the chain dispatch. Run 33934541092. | +| c8 `canary-apply` #2 (run 33935407461) | Provenance check **passed** (first consumption of ancestor evidence). Isolate → gen 113, drain, template+MIG applied 01:14–01:22, new c8 came up on `519f4914` and `relay_capacity_transition_verified` (migration-only, image exact, heartbeat fresh) at 01:23:50. Then the step's next call, `curl --fail-with-body` to c8 `/v1/admin/runtime-status`, got a **503 with a 27-byte body** at 01:23:51 and the step exited 22. Director `cell-status` at 01:23:50.8 returned 200; c8's own logs show nothing at that second; c8 health/ready both 200 seconds later; backend HEALTHY (the health check had just flipped TIMEOUT→HEALTHY at 01:22:16 and UNKNOWN→HEALTHY at 01:23:47 as the new instance warmed). Read: a single 503 at the load-balancer/warm-up edge on a curl with no retry, on a cell that was already verified healthy one line earlier. Failsafe ran: c8 kept **migration-only**, rehome control disabled, selector gen 113. c8 is serving (40 controls at 01:39, sqlLatencyMsMax ~30 ms) on the target image, just not admitted for general traffic. Nothing to roll back. | +| Recovery | The job has an explicit resume path: `mode=rollback` with `rollback-image-digest` = the image the cell already runs skips isolate/apply, verifies, and restores general admission (`ROLLBACK_RESUME=true`). Dispatched gate #28 (run 33936966508) at gen 113 with c8 in migration-only; on green the chain dispatches that resume for c8 with rollback digest `519f4914` and target `5aedbca5` (the validator only requires them to differ). | +| Follow-up | The verify step's bare `curl --fail-with-body` needs the same "no reading is not a verdict" retry the monitor got (#18723/#18740); a 503 immediately after `verify-relay-capacity-transition` passed is not evidence of a bad cell. | +| Monitor dry-run #28 | **Green** 01:58:59Z at gen 113 with c8 in migration-only. Run 33936966508. | +| c8 recovery (run 33937756402, `mode=rollback`, rollback digest = 519f4914) | **Succeeded** 02:02Z. `ROLLBACK_RESUME=true` path: isolate/apply skipped, converged-Terraform check passed, verify passed (`relay_capacity_transition_verified` general, image `519f4914`, heartbeat fresh), activate → **gen 114**, c8 general. No restart, no drain. c8 at 43 controls, sqlLatencyMsMax 36 ms. **c8 is the second cell on 519f4914** (with c7 on 85bf6799). Because the recovery ran as `rollback`, `seal_canary` was skipped, so no canary authority exists for a `batch-apply`; the next cell runs as another `canary-apply`. | +| Merged 02:05Z | stablyai/orca #18769: bounded retries on every admin-endpoint curl/fetch in the same-cap job and the rehome/canary/verify scripts (`--retry 3 --retry-delay 2 --retry-connrefused`, per-attempt bodies to a file; script helper 2 attempts on network error or 500/502/503/504 only; 4xx never retried; 650/650 tests). Trusted-path change, so the next gate runs at a commit containing it. | +| Pruner enabled (1.2) | Terraform applied 02:06Z (8 creates, then the deploy-identity job IAM grant after a propagation 404, 9/9). Job `orca-cloud-auth-token-pruner`, image `343a0915…`, scheduler `41 * * * *` UTC, budget 20 000 rows/run. First run by hand (exec `sf5ct`): cold start 3m20s, then `stopReason: time-budget` at 480 s: 73 batches, 365 000 scanned, **1 040 deleted** (1 021 revoked, 19 expired, 0 rotated), ~6.4 s/batch of 5 000, `completedFullPass: false`. No errors, no lock-wait or checkpoint alert. Scan-bound, not budget-bound: at this pace a full pass over the table takes many hourly runs, and the row budget is never the limiter. Leave the budget alone; watch hourly runs for `stopReason` and a rising `deletedRows` as the cursor reaches the rotated backlog. | +| Monitor dry-run #29 | **Green** 02:20:58Z at gen 114, main `e2b70a5eba` (contains #18740, #18754, #18769). Run 33938052374. | +| c9 `canary-apply` (run 33938818286) | **Succeeded end to end** 02:21–02:34Z: isolate → gen 115, drain, template+MIG to `519f4914`, verify passed on the first try (retry-hardened step), trust proof, activate → **gen 116**, general. `seal_canary` **succeeded**: batch authority now exists. c9 at 38 controls, sqlLatencyMsMax 33 ms. No `container die` in 30 min. Three cells on new images (c7 `85bf6799`, c8 and c9 `519f4914`); 17 serving cells still on `5aedbca5`. | +| Monitor dry-run #30 | Dispatched 02:36Z at gen 116 (run 33939533990). On green the chain dispatches **batch 1**: `batch-apply` c10,c13,c14,c15 bound to canary run 33938818286 (sealed at gen 116, same commit `e2b70a5eba`). Preflight: all four on `5aedbca5`, MIGs stable, no crash in 20 min. Sequential cells inside the job (wave-index 0..3), each with its own isolate/drain/apply/verify/restore, so ~12 min per cell, ~50 min total. | +| Monitor dry-run #30 verdict | **Green** 02:52:15Z at gen 116, `e2b70a5eba`. | +| Batch 1 (run 33940290163) | Dispatched 02:52:27Z: `batch-apply` c10,c13,c14,c15, canary authority run 33938818286, same commit. | +| Batch 1 attempt 1 (run 33940290163) | **Failed at 02:54:39Z in the live preflight, before any mutation**: `relay live preflight failed: cloud-monitoring/signal_stale`. The step's `--retry-freshness` (5 attempts, 15 s apart, freshness-only codes) is passed only for `WAVE_INDEX != 0`; the first cell takes a single sample, so one Cloud Monitoring publish lag > 180 s at that instant fails the batch. Every candidate series was current again by the time I checked. c10 untouched (template `…c10-20260827…`, 47 controls), no selector write, gen still 116, failsafe no-op. Gate #31 dispatched 02:57Z (run 33940508865); chain re-dispatches the same batch (canary authority 33938818286 still valid: same gen 116, same commit). Fix delegated: wave 0 gets the same freshness retry. | +| Monitor dry-run #31 | **Green** 03:13:26Z at gen 116; main at `cb7f7dd11a` with identical trusted code. Run 33940508865. | +| Batch 1 attempt 2 (run 33941253533) | Dispatched 03:13:38Z: c10,c13,c14,c15, canary authority 33938818286. Runs at `cb7f7dd11a` (batch authority is accepted across the ancestor since trusted paths are unchanged). | +| Merged 03:14Z | stablyai/orca #18778: `--retry-freshness` on every same-cap wave including the first, and the retry loop now stops before the next wait would push evidence past the wave's age bound (it was checked only at entry before). Twin carve-out in the capacity job filed as a follow-up. | +| Batch 1 cell 1 (c10) | **Succeeded** 03:14–03:27Z (preflight, drain, apply, verify, restore). c13 started 03:27Z. | +| Batch 1 cell 2 (c13) | **Succeeded** 03:27–03:38Z. c14 started 03:38Z. | +| Batch 1 cell 3 (c14) | **Succeeded** 03:38–03:50Z. c15 started 03:50Z. | +| Batch 1 complete (run 33941253533) | **All four succeeded** 03:13–04:00Z: c10, c13, c14, c15 on `519f4914`, selector **gen 124**. Fleet at 936 controls, 23 cells. Two `container die` at 03:35:41/44 were **c13's new container** exiting during boot (`applyPostgresSchema` → `Connection terminated due to connection timeout`, exit 1, 2 s runtime each) because the `cloud-sql-proxy` sidecar had not finished starting; the third start at 03:35:45 succeeded and c13 has been serving since (57 controls). A boot-order race in the container spec, not a serving-cell crash. Follow-up: schema pool should wait for the proxy socket, or the container should depend on the proxy's readiness. **8 cells on new images** (c7 85bf6799; c8, c9, c10, c13, c14, c15 519f4914), 12 on `5aedbca5`: c16, c19–c26 (US), c27–c29 (Asia). | +| Monitor dry-run #32 | **Green** 04:19:50Z at gen 124, main `436ef827dd` (contains #18778). Run 33943539025. | +| c16 `canary-apply` (run 33944255902) | Dispatched 04:20:02Z. On success it seals the authority for batch 2 (c19,c20,c21,c22). | +| c16 canary (run 33944255902) | **Succeeded** 04:20–04:32Z, activate → gen 126, batch authority sealed. 9 cells on new images. | +| Monitor dry-run #33 | Failed 04:58:56Z on `continuity_deadline_exceeded` (1 500 004 ms > 1 500 000 ms). One `signal_stale cloud_sql.lock_waits` at 04:46 (189 s vs 180 s bar, Cloud Monitoring publish lag) restarted the 15-min window at sample 12; the restart could not complete inside the 25-min continuity cap. No health failure at any sample; no `container die` since c16's own boot race at 04:30. Run 33944873727. Chain re-gates. Note for recalibration: `cloudDataMaxAgeMs: 180000` vs observed Cloud Monitoring publish lag of 181–255 s has now cost three gates (#20 twice, #33). | +| Freshness recalibration | stablyai/orca #18798 (open, merge after batch 2 dispatch): `cloudDataMaxAgeMs` 180 s → 330 s, derived from Google's documented visibility delays (Cloud Run 60+120 s, Cloud SQL 60+165 s) and the 5-min window-sum query (a label series that stops emitting reads as up to 300 s old while its sum is complete, which is the 255 s `auth.errors` case) plus ~30 s collect latency. Director-admin and the lock-wait carry keep their own 180 s pins. A freshness-only failure may miss 2 consecutive samples without restarting the window; the sample still counts and is still threshold-checked; a 3rd miss, collector failure, runner gap, or any breach restarts/freezes as before. 92/92 tests. | +| Monitor dry-run #34 | **Green** 05:17:31Z at gen 126, `436ef827dd`. Run 33946093029. | +| Batch 2 (run 33946819345) | Dispatched 05:17:43Z: c19,c20,c21,c22, canary authority 33944255902 (c16). | +| Merged 05:19Z | stablyai/orca #18798 (freshness bar 330 s + two-sample tolerance). Next gate runs at a commit containing it. | +| Batch 2 cell 1 (c19) | **Succeeded** 05:19–05:32Z. c20 started. | +| Batch 2 cell 2 (c20) | **Succeeded** 05:32–05:43Z. c21 started. | +| Batch 2 cell 3 (c21) | **Succeeded** 05:43–05:59Z. c22 started. | +| Batch 2 complete (run 33946819345) | **All four succeeded** 05:17–06:12Z: c19, c20, c21, c22 on `519f4914`, selector **gen 134**. Fleet at 1 090 controls, 23 cells, refresh 401s at baseline (1–4 per 3 min). One `container die` at 06:08:45 was **c22's new container** exiting during boot (exit 1, 2 s runtime; started 06:08:43, restarted 06:08:46 and serving since), the same proxy-sidecar boot race seen on c13 and c16. No serving-cell crash. **Census: 15 of 23 serving cells on new images** (c7 `85bf6799`; c8–c10, c13–c16, c19–c22 `519f4914`), 7 on `5aedbca5`: c23–c26 (US), c27–c29 (Asia). Next: gate at gen 134 → canary c23 → batch c24,c25,c26; then canary c27 → batch c28,c29. | +| Monitor dry-run #35 | **Green** 06:32:01Z at gen 134, `b33d1972bc` (contains #18798, first gate at the 330 s freshness bar). Run 33949334606. | +| c23 `canary-apply` (run 33950075843) | Dispatched 06:32:13Z at main `b0c67eaf88` (ancestor gate SHA, identical trusted code). On success it seals the authority for batch 3 (c24,c25,c26). | +| c23 canary (run 33950075843) | **Succeeded** 06:32–06:46Z, activate → gen 136, batch authority sealed. No `container die` during boot. 16 of 23 serving cells on new images; 6 on `5aedbca5` (c24–c26 US, c27–c29 Asia). | +| Monitor dry-run #36 | Dispatched 06:46Z at gen 136, run 33950746574 (`58553bfe1c`). On green the chain dispatches batch 3 (c24,c25,c26) under canary authority 33950075843. | +| Monitor dry-run #36 result | **Green** 07:02:49Z at gen 136, `58553bfe1c`. | +| Batch 3 (run 33951468008) | Dispatched 07:03Z: c24,c25,c26, canary authority 33950075843 (c23). | +| Batch 3 cell 1 (c24) | **Succeeded** 07:04–07:18Z. c25 started. | +| Batch 3 cell 2 (c25) | **Succeeded** 07:18–07:31Z. c26 started. | +| Batch 3 complete (run 33951468008) | **All three succeeded** 07:03–07:44Z: c24, c25, c26 on `519f4914`, selector **gen 142**. Fleet at ~1 230 controls, 23 cells, refresh 401s at baseline. **Zero `container die`** during the batch (no boot race on c24–c26). **All 20 US serving cells now on new images** (c7 `85bf6799`; c8–c10, c13–c16, c19–c26 `519f4914`). Remaining on `5aedbca5`: c27, c28, c29 (asia-east2, probe hard cap 3000 ms). | +| Monitor dry-run #37 | Dispatched 07:48Z at gen 142, run 33953555224 (`4c5077d57a`). On green the chain dispatches the c27 canary (first Asia cell). | +| Monitor dry-run #37 result | **Green** 08:04:24Z at gen 142, `4c5077d57a`. | +| c27 `canary-apply` (run 33954264945) | Dispatched 08:04Z, first Asia cell (asia-east2-a). On success it seals the authority for batch 4 (c28,c29). | +| c27 canary (run 33954264945) | **Failed closed before any mutation** 08:07:25Z at "Verify exact current generation, digest, cap, and rollback point": `runtime predecessor mismatch fields=regionalRehomeProtocol`. **Operator input error, not a cell fault**: the chain script hardcoded `target-rehome-protocol=1 / rollback-rehome-protocol=1` for every cell, but `relay_region_rehome_source_cell_ids` lists only the 16 US cells (c7–c10, c13–c16, c19–c26), so the Asia startup template omits `ORCA_RELAY_REHOME_*` and c27–c29 report protocol 0 by design. `MUTATION_STARTED` never set, failsafe no-op, selector stays gen 142, c27 still serving on `5aedbca5`, no `container die`. Gate #37 evidence consumed. Fix: chain script now takes `PROTO`; Asia round dispatches with protocol 0 (the per-host trust proof step is protocol-gated and skips, as designed for non-source cells). Follow-up: the job already reads `relay_region_rehome_source_cell_ids`; it could derive the expected protocol from membership instead of trusting the operator input. | +| Monitor dry-run #38 | Dispatched 08:12Z at gen 142, run 33954621425 (`e95d247be1`). On green the chain dispatches the c27 canary with protocol 0. | +| Monitor dry-run #38 result | **Green** 08:28:36Z at gen 142, `e95d247be1`. | +| c27 `canary-apply` #2 (run 33955359385) | Dispatched 08:28Z with `target/rollback-rehome-protocol=0`. | +| c27 canary #2 (run 33955359385) | **Failed closed, no mutation** 08:31:19Z. Predecessor check passed with protocol 0; the isolate step then died at argument parsing: `production capacity target is not approved`. The same-cap job shells out to `prepare-relay-production-capacity-canary.mjs` for isolate/drain/activate, whose `PRODUCTION_CAPACITY_CELL_IDS` allowlist is the 16 US capacity cells (c7–c26), while the same-cap wave validator (`SAME_CAP_CELLS`) approves all 19 serving cells including c27–c29. The Asia cells have never been through this job (their Aug 14 rollout used the asia-topology workflow). Both the isolate step and the failsafe threw before any HTTP call, so `MUTATION_STARTED=true` was written but nothing was isolated: selector stays gen 142, c27 general and serving on `5aedbca5`, no `container die`. Gate #38 evidence consumed. Fix: stablyai/orca #18811 (`--approved-cells same-cap` on all four invocations, default unchanged for the US capacity job, census test over every `SAME_CAP_CELLS` member × isolate/drain/activate + the job's cell-shape bash block; 525/525 script tests). Sweep of the other job scripts found no further Asia blocker; gate #39 (run 33955668701) dispatched at gen 142 to prove the selector is unchanged before the next attempt. | +| Monitor dry-run #39 | **Green** 08:51:28Z at gen 142: independent proof the selector was untouched by both failed c27 attempts. Not used for dispatch (its commit predates #18811). | +| Merged 08:51Z | stablyai/orca #18811 → main `12e05203a4`. | +| Monitor dry-run #40 | Dispatched 08:51Z at gen 142 on main `12e05203a4` (contains #18811), run 33956408337. On green the chain dispatches the c27 canary, protocol 0, third attempt. | +| Monitor dry-run #40 result | **Green** 09:08:03Z at gen 142, `12e05203a4`. | +| c27 `canary-apply` #3 (run 33957151726) | Dispatched 09:08Z, protocol 0, on main containing #18811. | +| c27 canary #3 (run 33957151726) | **Failed after isolate; failsafe held** 09:17:21Z. Live check 09:26Z: c27 at 0 controls (drained), template still `…20260814235757`, c28/c29 absorbed the hosts (37 each), fleet 1 404 controls / 23 cells, refresh 401s baseline, no `container die` in 60 m. Predecessor check and allowlist passed; isolate → **gen 143** (c27 migration-only), drain sent (graceMs 0, hosts reconnected via director to c28/c29/US). Terraform plan built correctly (template replace + MIG update to `519f4914`), then `validate-relay-capacity-plan.mjs --mode same-cap-cell` rejected it: `cell plan does not contain the reviewed image and capacity`. Its same-cap rule demands exactly one `ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT` and one `ORCA_RELAY_REHOME_AUDIENCE` printf in the startup script; Asia templates omit both because c27–c29 are not rehome sources (same root as attempt 1, third US-only assumption in the job). **No apply ran**: c27 template unchanged, still `5aedbca5`, isolated and draining (drain is one-way in-process; only a restart clears it). Failsafe re-asserted migration-only at gen 143 and rehome disabled. Recovery plan: fix validator (protocol-0 path: require the rehome lines *absent*), merge, gate at gen 143, then `mode=rollback` with rollback-image=`519f4914` (the failed-canary re-entry path; accepts draining + migration-only) to restart c27 onto the target image and restore it; then single-cell canaries for c28 and c29 (batch needs ≥2 cells). | +| Plan-validator fix | stablyai/orca #18818 (merged 09:41Z → main `9f2a9a248e`): `validate-relay-capacity-plan.mjs --regional-rehome-protocol 0|1` in same-cap-cell mode; protocol 0 requires the rehome lines *absent*, protocol 1 unchanged; both plan-validation calls in the job pass `DESIRED_REHOME_PROTOCOL`; census test now validates a correct plan for every `SAME_CAP_CELLS` member at its tfvars-derived protocol. 529/529. Residual: the operator-supplied protocol is still unbound for Asia cells (no `SOURCE_CELLS` cross-check outside us-central1), so a wrong value fails late at plan validation rather than early; deriving it from membership is the checklist follow-up. | +| Monitor dry-run #41 | Dispatched 09:42Z at gen 143 (c27 expected migration-only) on main `9f2a9a248e` (contains #18811 + #18818), run 33958728141. On green: c27 recovery via `mode=rollback`, rollback-image `519f4914`, protocol 0, confirmation `ROLL_BACK_RELAY_SAME_CAP`. | +| Monitor dry-run #41 result | **Green** 09:58:51Z at gen 143, `9f2a9a248e`. | +| c27 recovery #1 (run 33959789773, `mode=rollback`) | **Failed closed, no mutation** 10:09:21Z at `Verify monitor evidence provenance`: `relay monitor dry-run authority is incomplete or stale`. The dry-run authority is valid for 5 min after `completedAt` at wave 0 (`EVIDENCE_MAX_AGE_MS`); the gate completed 09:58:51Z but the operator poller (20 s `gh run view` loop) only observed completion at 10:07:09Z during a local network outage, so the dispatch landed at 10:07:11Z, 8 m 20 s after completion. Failed before the rollout lease, isolate, or any Terraform step; c27 unchanged (migration-only, drained, `5aedbca5`, gen 143). Every prior canary dispatched ≤15 s after gate green, so this is a dispatch-latency miss, not a job defect; the freshness bound behaved as designed. | +| Monitor dry-run #42 | Dispatched 18:39Z at gen 143 on main `af82126058` (trusted paths byte-identical to `9f2a9a248e`), run 33984753269. Recovery script re-armed behind it (same `mode=rollback` onto `519f4914`, protocol 0). | +| Monitor dry-run #42 result | **Green** 18:55:47Z at gen 143, `af82126058`. | +| c27 recovery #2 (run 33985902062, `mode=rollback`) | **Failed closed, no mutation** 19:05:02Z, same `authority is incomplete or stale`. Dispatch landed 19:02:39Z, 6 m 52 s after the gate completed. Root cause of both misses is the operator laptop sleeping during the 15 min gate wait (`pmset -g log`: asleep 18:52:28Z → 19:02:17Z; the morning miss coincided with a sleep/dark-wake cycle too), so the 20 s poller never ran inside the 5 min window. Not a job or evidence defect: the freshness bound did its job. Operator fix: poller now runs under `caffeinate -i`. | +| Monitor dry-run #43 | Dispatched 19:06Z at gen 143 on main `af82126058`, run 33986121849. Recovery armed behind it under `caffeinate`. | +| Monitor dry-run #43 result | **Green** 19:22:53Z at gen 143, `af82126058`. | +| c27 recovery #3 (run 33986948522, `mode=rollback`) | **Failed closed, no mutation** 19:25:48Z. Dispatched 13 s after gate green (authority accepted this time), then the live preflight recheck failed: `relay live preflight failed: active-probe/threshold_max`. That is the 2 000 ms `endpointLatencyMs` bar on one endpoint's slowest /health or /ready round trip from the runner (8 s fetch timeout, one retry). The error names no endpoint and the job log prints none; gate #43 had zero failures across 16 samples, so this was a transient probe slow-down in the ~3 min between gate and preflight. Live probe 19:32Z from the operator: director and auth ~130–190 ms, US cells ≤540 ms, Asia cells 690–1 315 ms (c28/c29 /health ~1.3 s, the closest to the bar; c27 ~0.9 s). Existing-only cells c1–c3, c6, c11, c12 return 503 on both paths as expected (unpowered). Failed before the rollout lease, isolate, or any Terraform step; c27 unchanged. Follow-up (checklist): preflight should print the failing signal and observed value. | +| Monitor dry-run #44 | Dispatched 19:33Z at gen 143 on main `062db77118`, run 33987646501. Recovery re-armed behind it. | +| Monitor dry-run #44 result | **Frozen red** 19:50:01Z after 13 samples: `active-probe/threshold_max cell.production-gce-c27.latency_ms observed=2568 threshold=2000`. No other failure, no continuity event, no `container die` fleet-wide in 60 m. `/health` is a static JSON reply (`app.ts`), so the slow round trip was `/ready` (the probe reports the max of the two) or the path to the cell. Cloud SQL logs for 19:49:38Z–19:51:58Z show six `could not obtain lock on row in relation "relay_cells"` errors and a time-triggered checkpoint completing at 19:50:36Z (write phase 270 s, the spread target, not a stall). c27 is drained with 0 controls, so its `/ready` dependency check was the only thing it was doing. Recovery script stopped as designed (no auto re-gate). Operator probe 19:53Z: c27 and c28 both bimodal, ~0.27 s or ~0.89 s per `/health` from the US, identical shape, nothing c27-specific. Attributing the one 2.6 s sample to the same shared-DB contention that produced the lock errors is the best available reading; the retry at gate #45 tests whether it recurs. | +| Monitor dry-run #45 | Dispatched 19:53Z at gen 143 on main `062db77118`, run 33988383401. Recovery re-armed behind it. | +| Monitor dry-run #45 result | **Frozen red** 19:54:47Z after 3 samples, same signal: `cell.production-gce-c27.latency_ms observed=2668 threshold=2000`. Two gates in a row now attribute a >2 s round trip to c27 while every other cell passes. | +| c27 `/ready` tail analysis | `/health` is static; `/ready` (`relay-readiness.ts`) fetches the auth JWKS (2 s timeout) then runs `SELECT 1`, cached 10 s. Operator probes 19:57Z–20:00Z, 15 each from the US: c27 and c28 have the **same** tail (0.27 s / 0.88 s modes, then 1.3 s, then 2.17–2.27 s at the top); US cells c8/c20 sit at 0.08–0.18 s. Auth JWKS latency over the last hour: 400 requests, max 20 ms, none over 1 s. So the tail is cell→Cloud SQL (US) round trips plus the runner→Asia hop, not auth and not c27-specific; c27 is drained (0 controls) so nothing local competes. Cloud SQL `could not obtain lock on row in relation "relay_cells"` runs at 17–78 per 10 min all day (NOWAIT inventory locks, expected under placement bursts) with no spike in the failing minutes. The bar (`endpointLatencyMs` 2 000 ms, one shot per minute, max of two paths) leaves Asia cells ~10% of samples from tripping; the gate got unlucky twice on c27 and lucky on c28/c29. Not a health finding. | +| Monitor dry-run #46 | Dispatched 20:01Z at gen 143 on main `062db77118`, run 33988810139. Recovery re-armed behind it. If this also freezes on an Asia probe, the next move is a per-region latency bar (or p50 over the window) in `incident-monitor.ts`, reviewed and merged before further Asia gates rather than retrying blindly. | +| Monitor dry-run #46 result | **Frozen red** 20:07:44Z after 7 samples, third time on `cell.production-gce-c27.latency_ms` (observed 2 685). Operator 40-sample `/ready` probe per Asia cell at 20:10Z: c27 p50 0.88 s / p90 2.15 s / max 2.26 s / 6 over 2 s; c28 p50 0.88 / p90 1.25 / max 2.25 / 1 over; c29 p50 0.88 / p90 0.89 / max 1.27 / 0 over. All 200. `/ready` (`relay-readiness.ts`) fetches the auth JWKS in us-central1 then `SELECT 1` on Cloud SQL in us-central1, so an Asia cell's readiness is two trans-Pacific hops plus the runner→Asia hop; the fleet-wide 2 000 ms bar was calibrated on US cells (0.08–0.5 s). c27 being drained and idle has no local load, so this is path latency, not health. **Stopped retrying gates.** Fix in flight: per-region `cell..latency_ms` bar (us-central1 stays 2 000, asia-east2 4 000; hard faults still caught by the health/ready equal-1 checks and the 8 s probe timeout) plus attributable preflight failure messages, via review + CI before the next Asia gate. | +| Merged 20:33Z | stablyai/orca #18877 → main `a3c1d32995`: per-region `cellEndpointLatencyMs` (us-central1 2 000, asia-east2 4 000; director/auth rules and the `endpointLatencyMs` key unchanged), region carried from tfvars onto every cell expectation, preflight failures now print `source/code signal observed= threshold=`. relay-ops 95/95, cloud suite 633 + 529 + 148 green. | +| Monitor dry-run #47 | Dispatched 20:34Z at gen 143 on main `a3c1d32995` (first gate with the per-region bar), run 33989896150. Recovery re-armed behind it. | +| Monitor dry-run #47 result | **Green** 20:38:09Z at gen 143 on `a3c1d32995`: first gate under the per-region bar, 16/16 samples, no Asia latency failure. | +| c27 recovery #4 (run 33990715317, `mode=rollback`) | **Success** 20:51Z. Dispatched 13 s after gate green. Isolate re-asserted migration-only at gen 143 (already isolated, no change), Terraform applied the same-cap template `…20260905204141` and the MIG replaced the instance, new incarnation on `519f4914`, protocol 0, transition verifier passed at migration-only (1 180 assignments carried, hard cap 3 000, heartbeat fresh), then activate → **gen 144**, c27 general, verifier passed again. No `container die` fleet-wide 19:55Z–20:52Z. c27 now runs the target image; c28/c29 remain on `5aedbca5` (template `…20260814235757`). | +| Monitor dry-run #48 | Dispatched 20:53Z at gen 144 (c27 back in general, MIG = c17,c18) on main `61ebffa86e` (trusted paths identical to `a3c1d32995`), run 33991385880. On green the chain dispatches the c28 `canary-apply`, protocol 0. | +| Monitor dry-run #48 result | **Green** 21:08Z at gen 144, 16/16 samples, no Asia latency failure. Main had moved to `5cec2c2dfc`; the chain verified the trusted paths were identical to the gate commit and dispatched 12 s after green. | +| c28 canary (run 33992169289, `canary-apply`) | **Success** 21:27Z. Isolate → migration-only at **gen 145**, drain already clear, verifier passed on the old image (1 220 assignments carried, hard cap 3 000, heartbeat fresh), Terraform applied same-cap template `…20260905211352`, new incarnation on `519f4914` at protocol 0, verifier passed again at migration-only, activate → **gen 146**, c28 general, verifier passed (1 219 assignments). Seal step recorded the canary. No `container die` fleet-wide 21:08Z–21:30Z. Only c29 remains on `5aedbca5`. | +| Monitor dry-run #49 | Dispatched 21:33Z at gen 146 (c28 back in general, MIG = c17,c18) on main `dce5ebd83d` (trusted paths identical to `a3c1d32995`), run 33993075948. On green the chain dispatches the c29 `canary-apply`, protocol 0, the last Roll 1 cell. | +| Monitor dry-run #49 result | **Frozen red** 21:52:24Z, `active-probe/continuity_deadline_exceeded observed=1500005 threshold=1500000`. One continuity event at 21:41:27Z, `cloud-monitoring/collector_failed` (a Cloud Monitoring read failed, not tolerated), which reset the continuous window at sample 14; the restarted window reached 10 samples before the 25-minute lineage cap (`INCIDENT_PRE_DRAIN_MAX_LINEAGE_MS`) expired. No health failure in any of the 25 samples, no Asia latency failure, no `container die`. Monitor-side transient, not a fleet finding. The chain re-gated automatically after its 2-minute back-off. | +| Monitor dry-run #50 | Dispatched 21:54Z at gen 146 on main `51eed5a1bc`, run 33994385666. **Green** 22:10Z, 16/16 samples. Main had moved to `d7767fb196`; trusted paths identical to `a3c1d32995`. Chain dispatched the c29 `canary-apply` (run 33995164002, protocol 0) 12 s after green. | +| c29 canary (run 33995164002, `canary-apply`) | **Success** 22:27Z. Isolate → migration-only at **gen 147**, verifier passed on the old image (1 199 assignments), Terraform applied same-cap template `…20260905221622`, new incarnation on `519f4914` at protocol 0, verifier passed at migration-only, activate → **gen 148**, c29 general, verifier passed (1 199 assignments carried). No `container die` fleet-wide 22:11Z–22:30Z. | +| **Roll 1 complete** | Image census 22:30Z from MIG templates: c8–c10, c13–c16, c19–c29 on `519f4914` (18 cells); c7 on `85bf6799` (the earlier rehearsal image, carries the same fix); existing-only c1–c6, c11, c12 and migration-only c17, c18 untouched by design. No serving cell remains on `5aedbca5`. Selector gen 148, membership unchanged from the start of the roll. Zero relay container exits fleet-wide across the roll (01:14Z–22:30Z). Gates used: #19–#50; freezes were all monitor-side (provenance, freshness, flat Asia latency bar, one Cloud Monitoring collector failure), none a fleet health finding. Roll 2 (fresh image with #18722 + #18720) is the next data-plane step and waits on the owner's private-IP window decision. | + +## Roll 2 (image `4916ed67`, 2026-09-06) + +| Step | Result | Evidence | +|---|---|---| +| Docs split | #18958 merged `3bb038a185` (findings, checklist, roadmap, Roll 2 plan). | | +| Code PR | #18959 merged `61b09b7a02` (rebase of #18565 onto main; desktop rotation change dropped since #18719 shipped a proportional version). Two Opus review rounds: round 1 caught the mobile fail-fast rejecting on any socket close (one AP flap would book the 60 s cooldown) → 2 s grace, re-armed once on `handshaking`; round 2 caught a removed jitter assertion that let a one-sided jitter pass → exact pin on the top of the band. Control lease 55 min → 6 h ± 30 min. | | +| Image publish | run 34002233801 → `sha256:4916ed676d8389f694a648e750f1112d9002d68c84a1e0c7af828d5af129de62`; mirrored to staging (run 34002326150). | | +| Staging cell smoke | **Dropped.** Staging C4 is pinned to the Asia launch digest by `relay-staging-c4-refresh-workflow.test.mjs` (with production c27–c29 tfvars and the C4 recovery workflow) and the only C4 image-refresh path pins its accepted predecessor to an older digest. Re-pinning all of it for a smoke widens into the Asia launch machinery; #18969 closed. Roll 2 follows the Roll 1 path: director first, c7 as the rehearsal cell. | | +| Director deploy | run 34002673626 **success** 01:02Z: serving `orca-cloud-relay-00575-leq` on `4916ed67`, `00574-wag` (same image) tagged `selector-rollback`, `00569-ret` (`519f4914`) still deployable. Baseline before: 1 director Postgres retry in the prior hour, 0 `container die`. | | +| c7 `verify` (read-only) | run 34002885408 dispatched 01:03Z, target `4916ed67`, rollback `85bf6799`, protocol 1, gen 148. | | diff --git a/cloud/docs/relay-roll2-plan-2026-09.md b/cloud/docs/relay-roll2-plan-2026-09.md new file mode 100644 index 00000000000..f84de39163f --- /dev/null +++ b/cloud/docs/relay-roll2-plan-2026-09.md @@ -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- --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= + -f regional-placement-mode=preserve -f prune-incompatible-revisions=false + -f expected-rehome-generation=12 -f bootstrap-runtime-identity=false + -f predecessor-image-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=, 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=, 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 `) 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. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ebf4d275678..7e0009b3a24 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -19,6 +19,7 @@ const { } = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') +const { signWindowsUninstallerViaSignPath } = require('./scripts/windows-uninstaller-signing.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -401,9 +402,17 @@ module.exports = { // name is absent. An unsigned build that still claimed 'SignPath Foundation' // would therefore reject its own channel's next build — and its way back to // stable with it. Dropping it is what makes dev→dev and dev→stable work. - ...(isWinDevChannel - ? { verifyUpdateCodeSignature: false } - : { signtoolOptions: { publisherName: 'SignPath Foundation' } }), + // Why a sign hook on a build that does not sign: it is the only moment + // electron-builder exposes the NSIS uninstaller (built in its own makensis + // pass, embedded, then deleted). The hook signs nothing — it relays the file + // to and from the CI SignPath request, and is inert when the relay env vars + // are unset, so local and dev builds are unaffected. publisherName stays on + // its existing channel split above. + signtoolOptions: { + sign: signWindowsUninstallerViaSignPath, + ...(isWinDevChannel ? {} : { publisherName: 'SignPath Foundation' }) + }, + ...(isWinDevChannel ? { verifyUpdateCodeSignature: false } : {}), extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('win32'), diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index ca80c99fc6d..d89439073ab 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -49,22 +49,48 @@ ; --------------------------------------------------------------------------- ; Clean up the relocated terminal daemon on a REAL uninstall. ; -; Why: the daemon host is deliberately copied to a distinct image name -; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app -; UPDATES cannot kill it — that relocation is what keeps terminals alive across -; updates. The same design means a normal uninstall's process sweep and file -; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; Why: the daemon host is deliberately copied OUT of the install dir into +; %LOCALAPPDATA%\Orca\daemon-host so that app UPDATES cannot kill it — +; electron-builder's kill sweep selects processes whose image path is under +; $INSTDIR, and that relocation is what keeps terminals alive across updates. +; The same design means a normal uninstall's process sweep and file removal both +; miss it, leaving an orphaned daemon plus its runtime copy behind. ; ; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as ; part of uninstallOldVersion on EVERY update, and killing the daemon there would ; defeat the whole feature. Only clean up on a genuine uninstall. ; -; The image name and the LOCALAPPDATA folder name must stay in sync with -; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in -; src/main/daemon/daemon-host-relocation.ts. +; The LOCALAPPDATA folder name must stay in sync with LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. See +; docs/reference/windows-daemon-host-relocation.md. !macro customUnInstall ${ifNot} ${isUpdated} - nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + Push $0 + Push $1 + Push $2 + ; The host exe is a verbatim copy of the app exe, so the app's own image name + ; reaches it; the second name covers hosts left by builds that renamed the copy. + ; Filtered to the current user like upstream's per-user KILL_PROCESS, so an + ; elevated machine-wide uninstall cannot reach another logged-on user's session. + ; NSIS expands USERNAME itself: routing through cmd.exe only to get %USERNAME% + ; would add two interpreter spawns to the uninstall path for nothing. + ReadEnvStr $1 USERNAME + ${if} $1 == "" + ; Measured: taskkill rejects an empty filter value outright ("The search filter + ; cannot be recognized") and kills nothing, so with no USERNAME to scope by, + ; kill unfiltered rather than not at all. USERNAME is set in every session an + ; uninstaller runs in, so this is a backstop, not the expected path. + StrCpy $2 "" + ${else} + StrCpy $2 '/FI "USERNAME eq $1"' + ${endIf} + nsExec::Exec 'taskkill /F /IM "${APP_EXECUTABLE_FILENAME}" $2' + Pop $0 + nsExec::Exec 'taskkill /F /IM "orca-terminal-daemon.exe" $2' + Pop $0 + Pop $2 + Pop $1 + Pop $0 ; Give the OS a moment to release the image lock before removing the tree. Sleep 500 RMDir /r "$LOCALAPPDATA\Orca\daemon-host" diff --git a/config/oxlint-performance-audit.json b/config/oxlint-performance-audit.json new file mode 100644 index 00000000000..2912c6b8e03 --- /dev/null +++ b/config/oxlint-performance-audit.json @@ -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.*"] +} diff --git a/config/oxlint-plugins/sort-comparator-performance.mjs b/config/oxlint-plugins/sort-comparator-performance.mjs new file mode 100644 index 00000000000..cd3444cf65f --- /dev/null +++ b/config/oxlint-plugins/sort-comparator-performance.mjs @@ -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 } } +} diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 10780f5288a..fe5e4be44b1 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -27,15 +27,424 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7 "/guard:cf", "/sdl", diff --git a/src/process.cc b/src/process.cc -index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644 +index 3eea92077c4d1d433119361d5c432881859131e9..738775f6fcdfb676054386fe34c0380327ed1863 100644 --- a/src/process.cc +++ b/src/process.cc -@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector& process_info, - process_info.push_back(std::move(pinfo)); - process_count++; - } +@@ -1,108 +1,112 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-#include "process.h" +-#include "process_commandline.h" +- +-#include +-#include +-#include +- +-uint32_t GetRawProcessList(std::vector& process_info, +- DWORD process_data_flags) { +- // Fetch the PID and PPIDs +- PROCESSENTRY32 process_entry = { 0 }; +- DWORD parent_pid = 0; +- uint32_t process_count = 0; +- HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); +- process_entry.dwSize = sizeof(PROCESSENTRY32); +- if (Process32First(snapshot_handle, &process_entry)) { +- do { +- if (process_entry.th32ProcessID != 0) { +- ProcessInfo pinfo; +- pinfo.pid = process_entry.th32ProcessID; +- pinfo.ppid = process_entry.th32ParentProcessID; +- +- if (MEMORY & process_data_flags) { +- GetProcessMemoryUsage(pinfo); +- } +- +- if (COMMANDLINE & process_data_flags) { +- GetProcessCommandLine(pinfo); +- } +- +- strcpy(pinfo.name, process_entry.szExeFile); +- process_info.push_back(std::move(pinfo)); +- process_count++; +- } - } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry)); +- } +- +- CloseHandle(snapshot_handle); +- return process_count; +-} +- +-void GetProcessMemoryUsage(ProcessInfo& process_info) { +- DWORD pid = process_info.pid; +- HANDLE hProcess; +- PROCESS_MEMORY_COUNTERS pmc; +- +- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid); +- +- if (hProcess == NULL) { +- return; +- } +- +- if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) { +- process_info.memory = (DWORD)pmc.WorkingSetSize; +- } +- +- CloseHandle(hProcess); +-} +- +-// Per documentation, it is not recommended to add or subtract values from the FILETIME +-// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows. +-// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead. +-// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx +-ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) { +- ULARGE_INTEGER kt, ut; +- kt.LowPart = (*kernelTime).dwLowDateTime; +- kt.HighPart = (*kernelTime).dwHighDateTime; +- +- ut.LowPart = (*userTime).dwLowDateTime; +- ut.HighPart = (*userTime).dwHighDateTime; +- +- return kt.QuadPart + ut.QuadPart; +-} +- +-void GetCpuUsage(Cpu& cpu_info, bool first_pass) { +- DWORD pid = cpu_info.pid; +- HANDLE hProcess; +- +- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid); +- +- if (hProcess == NULL) { +- return; +- } +- +- FILETIME creationTime, exitTime, kernelTime, userTime; +- FILETIME sysIdleTime, sysKernelTime, sysUserTime; +- if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime) +- && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) { +- if (first_pass) { +- cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime); +- cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime); +- } else { +- ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime); +- ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime); +- +- cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime); +- } +- } else { +- cpu_info.cpu = std::numeric_limits::quiet_NaN(); +- } +- +- CloseHandle(hProcess); ++/*--------------------------------------------------------------------------------------------- ++ * Copyright (c) Microsoft Corporation. All rights reserved. ++ * Licensed under the MIT License. See License.txt in the project root for license information. ++ *--------------------------------------------------------------------------------------------*/ ++ ++#include "process.h" ++#include "process_commandline.h" ++ ++#include ++#include ++#include ++ ++uint32_t GetRawProcessList(std::vector& process_info, ++ DWORD process_data_flags) { ++ // Fetch the PID and PPIDs ++ PROCESSENTRY32 process_entry = { 0 }; ++ DWORD parent_pid = 0; ++ uint32_t process_count = 0; ++ HANDLE snapshot_handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); ++ process_entry.dwSize = sizeof(PROCESSENTRY32); ++ if (Process32First(snapshot_handle, &process_entry)) { ++ do { ++ if (process_entry.th32ProcessID != 0) { ++ // Value-initialize: `memory` is otherwise stack garbage when the flag is unset. ++ ProcessInfo pinfo{}; ++ pinfo.pid = process_entry.th32ProcessID; ++ pinfo.ppid = process_entry.th32ParentProcessID; ++ ++ if (MEMORY & process_data_flags) { ++ GetProcessMemoryUsage(pinfo); ++ } ++ ++ if (COMMANDLINE & process_data_flags) { ++ GetProcessCommandLine(pinfo); ++ } ++ ++ strcpy(pinfo.name, process_entry.szExeFile); ++ process_info.push_back(std::move(pinfo)); ++ process_count++; ++ } + } while (Process32Next(snapshot_handle, &process_entry)); - } - - CloseHandle(snapshot_handle); ++ } ++ ++ CloseHandle(snapshot_handle); ++ return process_count; ++} ++ ++void GetProcessMemoryUsage(ProcessInfo& process_info) { ++ DWORD pid = process_info.pid; ++ HANDLE hProcess; ++ PROCESS_MEMORY_COUNTERS pmc; ++ ++ // PROCESS_VM_READ is never used here -- GetProcessMemoryInfo reads counters the ++ // kernel keeps, not the address space -- and acquiring it is what EDR scores. ++ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); ++ ++ if (hProcess == NULL) { ++ return; ++ } ++ ++ if (GetProcessMemoryInfo(hProcess, &pmc, sizeof(pmc))) { ++ process_info.memory = (DWORD)pmc.WorkingSetSize; ++ } ++ ++ CloseHandle(hProcess); ++} ++ ++// Per documentation, it is not recommended to add or subtract values from the FILETIME ++// structure, or to cast it to ULARGE_INTEGER as this can cause alignment faults on 64-bit Windows. ++// Copy the high and low part to a ULARGE_INTEGER and peform arithmetic on that instead. ++// See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724284(v=vs.85).aspx ++ULONGLONG GetTotalTime(const FILETIME* kernelTime, const FILETIME* userTime) { ++ ULARGE_INTEGER kt, ut; ++ kt.LowPart = (*kernelTime).dwLowDateTime; ++ kt.HighPart = (*kernelTime).dwHighDateTime; ++ ++ ut.LowPart = (*userTime).dwLowDateTime; ++ ut.HighPart = (*userTime).dwHighDateTime; ++ ++ return kt.QuadPart + ut.QuadPart; ++} ++ ++void GetCpuUsage(Cpu& cpu_info, bool first_pass) { ++ DWORD pid = cpu_info.pid; ++ HANDLE hProcess; ++ ++ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION. ++ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); ++ ++ if (hProcess == NULL) { ++ return; ++ } ++ ++ FILETIME creationTime, exitTime, kernelTime, userTime; ++ FILETIME sysIdleTime, sysKernelTime, sysUserTime; ++ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime) ++ && GetSystemTimes(&sysIdleTime, &sysKernelTime, &sysUserTime)) { ++ if (first_pass) { ++ cpu_info.initialProcRunTime = GetTotalTime(&kernelTime, &userTime); ++ cpu_info.initialSystemTime = GetTotalTime(&sysKernelTime, &sysUserTime); ++ } else { ++ ULONGLONG endProcTime = GetTotalTime(&kernelTime, &userTime); ++ ULONGLONG endSysTime = GetTotalTime(&sysKernelTime, &sysUserTime); ++ ++ cpu_info.cpu = 100.0 * (endProcTime - cpu_info.initialProcRunTime) / (endSysTime - cpu_info.initialSystemTime); ++ } ++ } else { ++ cpu_info.cpu = std::numeric_limits::quiet_NaN(); ++ } ++ ++ CloseHandle(hProcess); + } +\ No newline at end of file +diff --git a/src/process_commandline.cc b/src/process_commandline.cc +index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3210c3cfd 100644 +--- a/src/process_commandline.cc ++++ b/src/process_commandline.cc +@@ -1,67 +1,125 @@ +-/*--------------------------------------------------------------------------------------------- +- * Copyright (c) Microsoft Corporation. All rights reserved. +- * Licensed under the MIT License. See License.txt in the project root for license information. +- *--------------------------------------------------------------------------------------------*/ +- +-#include "process.h" +-#include "process_commandline.h" +-#include +-#include +-#include +- +-bool GetProcessCommandLine(ProcessInfo& process_info) { +- HINSTANCE ntdll = GetModuleHandleW(L"ntdll.dll"); +- if (!ntdll) { +- return false; +- } +- +- decltype(NtQueryInformationProcess)* nt_query_information_process = +- reinterpret_cast( +- GetProcAddress(ntdll, "NtQueryInformationProcess")); +- +- if (!nt_query_information_process) { +- return false; +- } +- +- PROCESS_BASIC_INFORMATION pbi{}; +- PEB peb = {NULL}; +- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL}; +- +- // Get process handle +- DWORD pid = process_info.pid; +- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid); +- if (hProcess == INVALID_HANDLE_VALUE) { +- return false; +- } +- +- // Get Process Environment Block (PEB) +- NTSTATUS status = nt_query_information_process(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr); +- if (NT_SUCCESS(status) && pbi.PebBaseAddress) { +- // Read PEB +- if (ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr)) { +- // Read the processs parameters +- if (ReadProcessMemory(hProcess, peb.ProcessParameters, &process_parameters, sizeof(RTL_USER_PROCESS_PARAMETERS), nullptr)) { +- if (process_parameters.CommandLine.Length > 0) { +- std::wstring buffer; +- buffer.resize(process_parameters.CommandLine.Length / sizeof(wchar_t)); +- if (ReadProcessMemory(hProcess, process_parameters.CommandLine.Buffer, &buffer[0], process_parameters.CommandLine.Length, nullptr)) { +- int wide_length = static_cast(buffer.length()); +- int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length, +- NULL, 0, NULL, NULL); +- if (charcount) { +- process_info.commandLine.resize(static_cast(charcount)); +- WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length, +- &process_info.commandLine[0], charcount, +- NULL, NULL); +- } +- CloseHandle(hProcess); +- return true; +- } +- } +- } +- } +- } +- +- CloseHandle(hProcess); +- return false; +-} ++/*--------------------------------------------------------------------------------------------- ++ * Copyright (c) Microsoft Corporation. All rights reserved. ++ * Licensed under the MIT License. See License.txt in the project root for license information. ++ *--------------------------------------------------------------------------------------------*/ ++ ++#include "process.h" ++#include "process_commandline.h" ++#include ++#include ++#include ++ ++namespace { ++ ++// Windows 8.1 and later hand back a process's command line as a UNICODE_STRING ++// the kernel builds, needing only PROCESS_QUERY_LIMITED_INFORMATION. ++// ++// There is deliberately no PEB fallback. Reading the command line out of the ++// target's address space -- opening it for VM reads and then chaining ++// memory reads across every pid on a timer -- is the credential-dumping ++// primitive this reader exists to not perform, so it is absent from the binary ++// rather than one anomalous NTSTATUS away. Electron's floor is Windows 10, so ++// every OS Orca supports has this class; if a hooked ntdll refuses it anyway, ++// the command line comes back empty, which callers already handle, instead of ++// silently reinstating the primitive on exactly the instrumented machines this ++// reader was written for. ++const ULONG kProcessCommandLineInformation = 60; ++ ++const NTSTATUS kStatusInfoLengthMismatch = static_cast(0xC0000004L); ++const NTSTATUS kStatusBufferTooSmall = static_cast(0xC0000023L); ++ ++// A command line is a UNICODE_STRING, whose Length is a USHORT, so the kernel ++// can never need more than the header plus 64 KiB. Refusing anything larger ++// keeps a bogus size from throwing bad_alloc out of a scan that has already ++// walked most of the table. ++const ULONG kMaxCommandLineBytes = sizeof(UNICODE_STRING) + 0xFFFF + sizeof(wchar_t); ++ ++// winternl.h's PROCESSINFOCLASS does not name class 60 and its enumerator range ++// stops far short of it, so the class travels as a ULONG rather than a cast enum. ++typedef NTSTATUS(NTAPI* NtQueryInformationProcessFn)(HANDLE, ULONG, PVOID, ULONG, PULONG); ++ ++// ntdll ships no import library for this entry point; it has to be resolved. ++NtQueryInformationProcessFn ResolveNtQueryInformationProcess() { ++ HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); ++ if (!ntdll) { ++ return nullptr; ++ } ++ return reinterpret_cast( ++ GetProcAddress(ntdll, "NtQueryInformationProcess")); ++} ++ ++NtQueryInformationProcessFn NtQueryInformationProcessEntry() { ++ static NtQueryInformationProcessFn entry = ResolveNtQueryInformationProcess(); ++ return entry; ++} ++ ++bool StoreCommandLineUtf8(ProcessInfo& process_info, const wchar_t* data, size_t wide_length) { ++ if (wide_length == 0) { ++ return false; ++ } ++ int length = static_cast(wide_length); ++ int charcount = WideCharToMultiByte(CP_UTF8, 0, data, length, NULL, 0, NULL, NULL); ++ if (!charcount) { ++ return false; ++ } ++ process_info.commandLine.resize(static_cast(charcount)); ++ WideCharToMultiByte(CP_UTF8, 0, data, length, &process_info.commandLine[0], charcount, NULL, ++ NULL); ++ return true; ++} ++ ++} // namespace ++ ++bool GetProcessCommandLine(ProcessInfo& process_info) { ++ NtQueryInformationProcessFn query = NtQueryInformationProcessEntry(); ++ if (!query) { ++ return false; ++ } ++ ++ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_info.pid); ++ if (process == NULL) { ++ return false; ++ } ++ ++ ULONG size = 0; ++ NTSTATUS status = query(process, kProcessCommandLineInformation, nullptr, 0, &size); ++ if (NT_SUCCESS(status)) { ++ // Nothing was written, so there is no command line to read. ++ CloseHandle(process); ++ return false; ++ } ++ if (status != kStatusInfoLengthMismatch && status != kStatusBufferTooSmall) { ++ CloseHandle(process); ++ return false; ++ } ++ if (size < sizeof(UNICODE_STRING) || size > kMaxCommandLineBytes) { ++ CloseHandle(process); ++ return false; ++ } ++ ++ std::vector buffer(size); ++ status = query(process, kProcessCommandLineInformation, &buffer[0], size, &size); ++ CloseHandle(process); ++ if (!NT_SUCCESS(status)) { ++ return false; ++ } ++ ++ // Header and characters arrive in one allocation, but treat the header as ++ // untrusted: a hooked ntdll is the case this reader is written for, and an ++ // unchecked Buffer/Length here would be an over-read encoded straight into JS. ++ // Bound against buffer.size(), never `size` -- the second query overwrote it. ++ const UNICODE_STRING* command_line = reinterpret_cast(&buffer[0]); ++ const unsigned char* begin = &buffer[0]; ++ const unsigned char* end = begin + buffer.size(); ++ const unsigned char* chars = reinterpret_cast(command_line->Buffer); ++ if (chars == nullptr || chars < begin + sizeof(UNICODE_STRING) || chars > end || ++ command_line->Length > static_cast(end - chars)) { ++ return false; ++ } ++ ++ // True only when a command line was actually stored, so "empty" and "not ++ // recovered" stay the same answer they were before this reader replaced the ++ // PEB read. `src/process.cc` discards the result either way. ++ return StoreCommandLineUtf8(process_info, command_line->Buffer, ++ command_line->Length / sizeof(wchar_t)); ++} diff --git a/config/performance-audit.md b/config/performance-audit.md new file mode 100644 index 00000000000..f105bfbe787 --- /dev/null +++ b/config/performance-audit.md @@ -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. diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 9f5885edc8a..d48a239354f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." } ] } diff --git a/config/scripts/benchmark-browser-tunnel-framing.mjs b/config/scripts/benchmark-browser-tunnel-framing.mjs new file mode 100644 index 00000000000..e91fd0887f6 --- /dev/null +++ b/config/scripts/benchmark-browser-tunnel-framing.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' + +// Run from the worktree root: node config/scripts/benchmark-browser-tunnel-framing.mjs [base-ref] +const path = 'src/shared/browser-network-tunnel-stream-framing.ts' +const baselineRef = process.argv[2] ?? 'HEAD' +const beforeSource = execFileSync('git', ['show', `${baselineRef}:${path}`], { + encoding: 'utf8' +}) +const afterSource = readFileSync(path, 'utf8') +const load = (source) => + import( + `data:text/javascript;base64,${Buffer.from( + stripTypeScriptTypes(source, { mode: 'transform' }) + ).toString('base64')}` + ) +const before = await load(beforeSource) +const after = await load(afterSource) + +function measure(module, chunks, payload, repetitions) { + let frameCount = 0 + let lastFrame + const onFrame = (frame) => { + frameCount++ + lastFrame = frame + } + const onError = (error) => { + throw error + } + const run = () => { + const decoder = new module.BrowserNetworkTunnelStreamFrameDecoder(onFrame, onError) + for (const chunk of chunks) { + decoder.feed(chunk) + } + } + run() + assert.deepEqual(lastFrame, payload) + const samples = [] + for (let sample = 0; sample < 5; sample++) { + const start = performance.now() + for (let iteration = 0; iteration < repetitions; iteration++) { + run() + } + samples.push((performance.now() - start) / repetitions) + } + assert.equal(frameCount, 1 + 5 * repetitions) + return samples.sort((a, b) => a - b)[2] +} + +function countCopies(module, chunks) { + const originalSet = Uint8Array.prototype.set + const originalSlice = Uint8Array.prototype.slice + let copied = 0 + Uint8Array.prototype.set = function (source, offset) { + copied += source.length + return originalSet.call(this, source, offset) + } + Uint8Array.prototype.slice = function (...args) { + const result = originalSlice.apply(this, args) + copied += result.length + return result + } + try { + const decoder = new module.BrowserNetworkTunnelStreamFrameDecoder( + () => {}, + (error) => { + throw error + } + ) + for (const chunk of chunks) { + decoder.feed(chunk) + } + } finally { + Uint8Array.prototype.set = originalSet + Uint8Array.prototype.slice = originalSlice + } + return copied +} + +const rows = [] +for (const [payloadBytes, chunkBytes, repetitions] of [ + [1, 5, 10000], + [64 * 1024, 65540, 1000], + [64 * 1024, 4096, 100], + [64 * 1024, 256, 25], + [64 * 1024, 16, 5], + [64 * 1024, 1, 1] +]) { + const payload = Uint8Array.from({ length: payloadBytes }, (_, index) => index % 251) + const encoded = before.encodeBrowserNetworkTunnelStreamFrame(payload) + const chunks = [] + for (let offset = 0; offset < encoded.length; offset += chunkBytes) { + chunks.push(encoded.subarray(offset, offset + chunkBytes)) + } + const beforeMs = measure(before, chunks, payload, repetitions) + const afterMs = measure(after, chunks, payload, repetitions) + rows.push({ + payloadBytes, + chunkBytes, + beforeMs: +beforeMs.toFixed(6), + afterMs: +afterMs.toFixed(6), + speedup: +(beforeMs / afterMs).toFixed(2), + beforeCopiedBytes: countCopies(before, chunks), + afterCopiedBytes: countCopies(after, chunks) + }) +} +console.log(JSON.stringify({ node: process.version, baselineRef, rows }, null, 2)) diff --git a/config/scripts/benchmark-cli-error-imports.mjs b/config/scripts/benchmark-cli-error-imports.mjs new file mode 100644 index 00000000000..a4648f84aec --- /dev/null +++ b/config/scripts/benchmark-cli-error-imports.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { existsSync, realpathSync } from 'node:fs' +import { delimiter, join, resolve } from 'node:path' + +// Emit each revision with tsc -p config/tsconfig.cli.json --outDir --composite false --incremental false. +// Run: node config/scripts/benchmark-cli-error-imports.mjs +const [beforeDir, afterDir] = process.argv.slice(2) +assert.ok(beforeDir && afterDir, 'Pass distinct before and after TypeScript output directories.') +assert.notEqual( + realpathSync(beforeDir), + realpathSync(afterDir), + 'Do not compare a build to itself.' +) +const entries = { + before: join(resolve(beforeDir), 'cli', 'index.js'), + after: join(resolve(afterDir), 'cli', 'index.js') +} +for (const entry of Object.values(entries)) { + assert.ok(existsSync(entry), `Missing emitted CLI: ${entry}`) +} + +const { runProcessSync } = createRequire(import.meta.url)( + join(resolve(afterDir), 'shared', 'child-process', 'run-process.js') +) + +const child = String.raw` + const { performance } = require('node:perf_hooks') + const { writeSync } = require('node:fs') + const { createHash } = require('node:crypto') + const { basename } = require('node:path') + let stdout = '', stderr = '' + process.stdout.write = (text) => { stdout += text; return true } + process.stderr.write = (text) => { stderr += text; return true } + const started = performance.now() + const cli = require(process.argv[1]) + const importMs = performance.now() - started + cli.main(JSON.parse(process.argv[2])).then(() => { + const totalMs = performance.now() - started + const modules = Object.keys(require.cache) + writeSync(1, JSON.stringify({ + importMs, totalMs, modules: modules.length, + featureFormatters: modules.filter((file) => ['browser', 'terminal', 'project', 'automation', 'workspace', 'computer'].some((name) => basename(file) === name + '-format.js')), + stdout: createHash('sha256').update(stdout).digest('hex'), + stderr: createHash('sha256').update(stderr).digest('hex'), + exitCode: process.exitCode || 0 + })) + process.exitCode = 0 + }).catch((error) => { writeSync(2, String(error)); process.exitCode = 1 }) +` +const cases = [ + ['--help'], + ['help', 'terminal', 'read'], + ['does-not-exist'], + ['computer', 'click', '--does-not-exist'], + ['does-not-exist', '--json'] +] +const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] +const summarize = (samples) => ({ + importMs: median(samples.map((sample) => sample.importMs)), + totalMs: median(samples.map((sample) => sample.totalMs)), + modules: samples[0].modules +}) +const rows = [] +for (const args of cases) { + const samples = { before: [], after: [] } + let expected + for (let run = 0; run < 22; run++) { + for (const variant of run % 2 ? ['after', 'before'] : ['before', 'after']) { + const result = runProcessSync({ + program: process.execPath, + args: ['-e', child, entries[variant], JSON.stringify(args)], + timeoutMs: 30_000, + env: { + ...process.env, + NODE_PATH: [resolve('node_modules'), process.env.NODE_PATH] + .filter(Boolean) + .join(delimiter) + } + }) + assert.equal(result.timedOut, false, 'CLI child timed out.') + assert.equal(result.code, 0, result.stderr) + const sample = JSON.parse(result.stdout) + const output = { stdout: sample.stdout, stderr: sample.stderr, exitCode: sample.exitCode } + expected ??= output + assert.deepEqual(output, expected, `${variant} output changed for ${args.join(' ')}`) + if (variant === 'after') { + assert.deepEqual( + sample.featureFormatters, + [], + 'Help and syntax errors must skip feature formatters.' + ) + } + if (run >= 2) { + samples[variant].push(sample) + } + } + } + assert.ok(samples.after[0].modules < samples.before[0].modules, 'Expected fewer loaded modules.') + rows.push({ + args, + before: summarize(samples.before), + after: summarize(samples.after), + output: expected, + samples + }) +} +console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + measurement: + 'Fresh-process import + main; excludes process creation; warmed filesystem; 2 warmups and 20 samples per variant, alternating order.', + entries, + rows + }, + null, + 2 + ) +) diff --git a/config/scripts/benchmark-cli-response-framing.mjs b/config/scripts/benchmark-cli-response-framing.mjs new file mode 100644 index 00000000000..40aab8d08f7 --- /dev/null +++ b/config/scripts/benchmark-cli-response-framing.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { readFileSync } from 'node:fs' +import Module from 'node:module' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Run from the worktree root: node config/scripts/benchmark-cli-response-framing.mjs +const sourcePath = 'src/cli/runtime/transport.ts' +const baselineRef = process.argv[2] +assert.ok(baselineRef, 'Pass the pre-change transport revision as base-ref.') +const beforeSource = execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], { + encoding: 'utf8' +}) +let chunks = [] + +async function loadTransport(source) { + const built = await build({ + stdin: { contents: source, loader: 'ts', resolveDir: dirname(resolve(sourcePath)) }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent' + }) + const module = new Module(resolve(sourcePath)) + const originalRequire = module.require.bind(module) + module.require = (name) => { + if (name === 'node:crypto') { + return { randomUUID: () => 'benchmark-request' } + } + if (name !== 'node:net') { + return originalRequire(name) + } + return { + createConnection() { + const socket = new EventEmitter() + socket.setEncoding = () => {} + socket.end = () => {} + socket.destroy = () => {} + socket.write = () => { + for (const chunk of chunks) { + socket.emit('data', chunk) + } + } + queueMicrotask(() => socket.emit('connect')) + return socket + } + } + } + module._compile(built.outputFiles[0].text, resolve(sourcePath)) + return module.exports.sendRequest +} + +const before = await loadTransport(beforeSource) +const after = await loadTransport(readFileSync(sourcePath, 'utf8')) +const metadata = { + runtimeId: 'benchmark-runtime', + authToken: 'benchmark-token', + transports: [{ kind: 'unix', endpoint: 'injected-socket' }] +} +const run = (sendRequest) => sendRequest(metadata, 'terminal.read', {}, 30000) + +async function measure(sendRequest, payloadBytes, repetitions) { + const warmup = await run(sendRequest) + assert.equal(warmup.result.data.length, payloadBytes) + const samples = [] + for (let sample = 0; sample < 5; sample++) { + const start = performance.now() + for (let iteration = 0; iteration < repetitions; iteration++) { + await run(sendRequest) + } + samples.push((performance.now() - start) / repetitions) + } + return samples.sort((a, b) => a - b)[2] +} + +async function searchedCharacters(sendRequest) { + const original = String.prototype.indexOf + let searched = 0 + String.prototype.indexOf = function (needle, position) { + if (needle === '\n') { + searched += this.length - (position ?? 0) + } + return original.call(this, needle, position) + } + try { + await run(sendRequest) + } finally { + String.prototype.indexOf = original + } + return searched +} + +const rows = [] +for (const [payloadBytes, chunkChars, repetitions] of [ + [32, 65536, 1000], + [1024 * 1024, 2 * 1024 * 1024, 20], + [1024 * 1024, 65536, 10], + [1024 * 1024, 4096, 5], + [4 * 1024 * 1024, 4096, 2], + [4 * 1024 * 1024, 256, 1] +]) { + const line = `${JSON.stringify({ + id: 'benchmark-request', + ok: true, + result: { data: 'x'.repeat(payloadBytes) }, + _meta: { runtimeId: 'benchmark-runtime' } + })}\n` + chunks = [] + for (let offset = 0; offset < line.length; offset += chunkChars) { + chunks.push(line.slice(offset, offset + chunkChars)) + } + const beforeMs = await measure(before, payloadBytes, repetitions) + const afterMs = await measure(after, payloadBytes, repetitions) + rows.push({ + payloadBytes, + chunkChars, + beforeMs: +beforeMs.toFixed(6), + afterMs: +afterMs.toFixed(6), + speedup: +(beforeMs / afterMs).toFixed(2), + beforeSearchedCharacters: await searchedCharacters(before), + afterSearchedCharacters: await searchedCharacters(after) + }) +} +console.log(JSON.stringify({ node: process.version, baselineRef, rows }, null, 2)) diff --git a/config/scripts/benchmark-explorer-dotfile-filter.mjs b/config/scripts/benchmark-explorer-dotfile-filter.mjs new file mode 100644 index 00000000000..e66a0ceb5af --- /dev/null +++ b/config/scripts/benchmark-explorer-dotfile-filter.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import Module from 'node:module' +import { resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pass the pre-change file-explorer-entries.ts snapshot as the only argument. +const baselinePath = process.argv[2] +assert.ok(baselinePath, 'Pass a pre-change file-explorer-entries.ts snapshot.') +const entry = 'src/renderer/src/components/right-sidebar/file-explorer-entries.ts' +const baseline = readFileSync(baselinePath, 'utf8') +assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.') + +async function load(useBaseline) { + const result = await build({ + stdin: { + contents: `export { isDotfileRelativePath } from './${entry}'; +export { createNameFilteredFileExplorerProjection } from './src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts';`, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent', + alias: { '@': resolve('src/renderer/src') }, + plugins: useBaseline + ? [ + { + name: 'baseline-dotfile-predicate', + setup(builder) { + builder.onLoad({ filter: /file-explorer-entries\.ts$/ }, () => ({ + contents: baseline, + loader: 'ts' + })) + } + } + ] + : [] + }) + const module = new Module(resolve('dotfile-benchmark.cjs')) + module.paths = Module._nodeModulePaths(process.cwd()) + module._compile(result.outputFiles[0].text, module.id) + return module.exports +} + +const versions = [await load(true), await load(false)] +let parityCases = 0 +function check(path, depth) { + assert.equal( + versions[0].isDotfileRelativePath(path), + versions[1].isDotfileRelativePath(path), + path + ) + parityCases++ + if (depth > 0) { + for (const character of ['.', '/', '\\', 'a', '\n']) { + check(path + character, depth - 1) + } + } +} +check('', 8) + +function measure(functions, iterations = 1) { + let sink = 0 + const run = (fn) => { + for (let i = 0; i < iterations; i++) { + sink += Number(fn()) + } + } + for (const fn of functions) { + for (let warmup = 0; warmup < 3; warmup++) { + run(fn) + } + } + const samples = [[], []] + for (let round = 0; round < 11; round++) { + for (const variant of round % 2 ? [1, 0] : [0, 1]) { + const start = performance.now() + run(functions[variant]) + samples[variant].push(performance.now() - start) + } + } + return { + beforeMs: samples[0].sort((a, b) => a - b)[5], + afterMs: samples[1].sort((a, b) => a - b)[5], + iterations, + sink + } +} + +const predicates = [] +for (const path of [ + 'a', + '.env', + 'packages/pkg/src/file.tsx', + `a${'.'.repeat(254)}`, + `${'/'.repeat(4096)}.`, + `${'../'.repeat(1000)}file.ts`, + '😀/.你好', + '\n/.\n' +]) { + check(path, 0) + predicates.push({ + pathLength: path.length, + prefix: path.slice(0, 40), + ...measure( + versions.map((version) => () => version.isDotfileRelativePath(path)), + 10_000 + ) + }) +} + +const projections = [] +for (const count of [1000, 10_000, 100_000]) { + for (const query of ['nonmatching-needle', 'file-42']) { + const args = { + ignoredSet: new Set(['unrelated']), + nameFilter: { + query, + relativePaths: Array.from( + { length: count }, + (_, i) => `packages/package-${i % 50}/src/components/section-${i % 10}/file-${i}.tsx` + ) + }, + showDotfiles: false, + showGitIgnoredFiles: false, + worktreePath: '/workspace' + } + const functions = versions.map( + (version) => () => version.createNameFilteredFileExplorerProjection(args) + ) + const rows = functions.map((fn) => { + const projection = fn() + return Array.from({ length: projection.getVisibleCount() }, (_, i) => + projection.getRowAtIndex(i) + ) + }) + assert.deepEqual(rows[0], rows[1]) + projections.push({ + count, + query, + visibleRows: rows[0].length, + ...measure(functions.map((fn) => () => fn().getVisibleCount())) + }) + } +} +console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + baselinePath: resolve(baselinePath), + parityCases, + samples: 11, + warmups: 3, + predicates, + projections + }, + null, + 2 + ) +) diff --git a/config/scripts/benchmark-sentinel-retention.mjs b/config/scripts/benchmark-sentinel-retention.mjs new file mode 100644 index 00000000000..93564eeac01 --- /dev/null +++ b/config/scripts/benchmark-sentinel-retention.mjs @@ -0,0 +1,72 @@ +import { strict as assert } from 'node:assert' +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' + +if (!global.gc) { + throw new Error('Run with node --expose-gc') +} +const root = resolve(import.meta.dirname, '../..') +const directory = await mkdtemp(join(tmpdir(), 'orca-sentinel-retention-')) +const output = join(directory, 'sentinel.cjs') +try { + await build({ + stdin: { + contents: `export {waitForSentinel} from './src/main/ssh/ssh-relay-deploy-helpers'; +export {RELAY_SENTINEL} from './src/main/ssh/relay-protocol';`, + resolveDir: root, + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + packages: 'external', + banner: { + js: `var require = require('node:module').createRequire(${JSON.stringify(join(root, 'package.json'))});` + }, + outfile: output + }) + const { waitForSentinel, RELAY_SENTINEL } = createRequire(import.meta.url)(output) + const held = [] + const banners = [] + for (let i = 0; i < 100; i++) { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + stdin: { write: () => true }, + close: () => {} + }) + const pending = waitForSentinel(channel) + banners.push(feedBanner(channel)) + channel.emit('data', Buffer.from(RELAY_SENTINEL)) + const transport = await pending + const received = [] + transport.onData((bytes) => received.push(bytes.toString())) + channel.emit('data', Buffer.from('frame')) + assert.deepEqual(received, ['frame']) + held.push({ channel, transport }) + } + await new Promise((resolve) => setImmediate(resolve)) + for (let i = 0; i < 5; i++) { + global.gc() + } + const retained = banners.filter((reference) => reference.deref() !== undefined).length + console.log( + JSON.stringify({ + connections: held.length, + bannerBytes: 65536, + retainedBannerBuffers: retained, + retainedBannerBytes: retained * 65536 + }) + ) +} finally { + await rm(directory, { recursive: true, force: true }) +} + +function feedBanner(channel) { + const banner = Buffer.alloc(65536, 120) + channel.emit('data', banner) + return new WeakRef(banner.buffer) +} diff --git a/config/scripts/benchmark-skill-depth.mjs b/config/scripts/benchmark-skill-depth.mjs new file mode 100644 index 00000000000..1ebb606f93c --- /dev/null +++ b/config/scripts/benchmark-skill-depth.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import Module from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +// Pass a pre-change skill-root-file-walk.ts snapshot as the only argument. +const baselinePath = process.argv[2] +const brokenLinks = process.argv.includes('--broken') +assert.ok(baselinePath, 'Pass a pre-change skill-root-file-walk.ts snapshot.') +const entry = 'src/main/skills/skill-root-file-walk.ts' +const baseline = readFileSync(baselinePath, 'utf8') +assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.') +let statCalls = 0 + +async function load(useBaseline) { + const result = await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent', + plugins: useBaseline + ? [ + { + name: 'baseline-skill-depth', + setup(builder) { + builder.onLoad({ filter: /skill-root-file-walk\.ts$/ }, () => ({ + contents: baseline, + loader: 'ts' + })) + } + } + ] + : [] + }) + const module = new Module(resolve('skill-depth-benchmark.cjs')) + module.paths = Module._nodeModulePaths(process.cwd()) + const originalRequire = module.require.bind(module) + module.require = (name) => + name === 'node:fs/promises' + ? { + ...fs, + stat: (...args) => { + statCalls++ + return fs.stat(...args) + } + } + : originalRequire(name) + module._compile(result.outputFiles[0].text, module.id) + return module.exports.findSkillFiles +} + +const before = await load(true) +const after = await load(false) +const median = (values) => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] +const temporaryRoot = await fs.mkdtemp(join(tmpdir(), 'orca-skill-depth-benchmark-')) +try { + for (const links of [0, 8, 100, 1000]) { + const root = join(temporaryRoot, String(links)) + const edge = join(root, 'a', 'b', 'c', 'd') + const target = join(temporaryRoot, 'target') + await fs.mkdir(edge, { recursive: true }) + await fs.mkdir(target, { recursive: true }) + await fs.writeFile(join(target, 'SKILL.md'), 'skill') + await fs.writeFile(join(edge, 'SKILL.md'), 'edge') + for (let index = 0; index < links; index++) { + await fs.symlink( + brokenLinks ? join(target, 'missing') : target, + join(edge, `link${index}`), + process.platform === 'win32' ? 'junction' : 'dir' + ) + } + for (const depth of [4, 5]) { + const timings = { before: [], after: [] } + const counts = {} + let rows + for (let sample = 0; sample < 13; sample++) { + const versions = + sample % 2 + ? [ + ['after', after], + ['before', before] + ] + : [ + ['before', before], + ['after', after] + ] + for (const [name, walk] of versions) { + statCalls = 0 + const start = performance.now() + const result = await walk(root, depth) + const elapsed = performance.now() - start + if (rows) { + assert.deepEqual(result, rows) + } + rows = result + counts[name] = statCalls + if (sample >= 2) { + timings[name].push(elapsed) + } + } + } + console.log( + JSON.stringify({ + links, + brokenLinks, + depth, + statCalls: counts, + rows: rows.length, + medianMs: { before: median(timings.before), after: median(timings.after) } + }) + ) + } + } +} finally { + await fs.rm(temporaryRoot, { recursive: true, force: true }) +} diff --git a/config/scripts/benchmark-tab-group-repair.mjs b/config/scripts/benchmark-tab-group-repair.mjs new file mode 100644 index 00000000000..17a1161fc4c --- /dev/null +++ b/config/scripts/benchmark-tab-group-repair.mjs @@ -0,0 +1,80 @@ +import { strict as assert } from 'node:assert' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const root = resolve(import.meta.dirname, '../..') +const source = join(root, 'src/renderer/src/store/slices/tab-group-reference-repair.ts') +const directory = await mkdtemp(join(tmpdir(), 'orca-tab-repair-')) +const current = await readFile(source, 'utf8') +const indexed = `const orderedTabIds = new Set(group.tabOrder) + const missingTabIds = ownedTabIds.filter((tabId) => !orderedTabIds.has(tabId))` +assert(current.includes(indexed), 'Expected indexed implementation') +try { + const implementations = [] + for (const baseline of [true, false]) { + const outfile = join(directory, baseline ? 'before.cjs' : 'after.cjs') + await build({ + stdin: { + contents: baseline + ? current.replace( + indexed, + 'const missingTabIds = ownedTabIds.filter((tabId) => !group.tabOrder.includes(tabId))' + ) + : current, + resolveDir: resolve(source, '..'), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + alias: { '@': join(root, 'src/renderer/src') } + }) + implementations.push(createRequire(import.meta.url)(outfile).appendOwnedTabIdsToGroups) + } + const rows = [] + for (const count of [1, 10, 100, 1_000, 10_000]) { + for (const missing of [false, true]) { + const ids = Array.from({ length: count }, (_, i) => `tab-${i}`) + const groups = [ + { id: 'group', worktreeId: 'workspace', activeTabId: null, tabOrder: ids, recentTabIds: [] } + ] + const owners = new Map(ids.map((id) => [missing ? `missing-${id}` : id, 'group'])) + assert.deepEqual(implementations[0](groups, owners), implementations[1](groups, owners)) + const iterations = Math.max(1, Math.floor(10_000 / count)) + const samples = [[], []] + for (let sample = -3; sample < 11; sample++) { + for (const index of sample % 2 === 0 ? [0, 1] : [1, 0]) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + implementations[index](groups, owners) + } + const elapsed = (performance.now() - start) / iterations + if (sample >= 0) { + samples[index].push(elapsed) + } + } + } + rows.push({ + count, + missing, + iterations, + beforeMs: samples[0].sort((a, b) => a - b)[5], + afterMs: samples[1].sort((a, b) => a - b)[5] + }) + } + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, samples: 11, warmups: 3, rows }, + null, + 2 + ) + ) +} finally { + await rm(directory, { recursive: true, force: true }) +} diff --git a/config/scripts/benchmark-transcript-reverse-lines.mjs b/config/scripts/benchmark-transcript-reverse-lines.mjs new file mode 100644 index 00000000000..e9d370d1839 --- /dev/null +++ b/config/scripts/benchmark-transcript-reverse-lines.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import Module from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const entry = 'src/shared/agent-hook-listener/transcript-reader.ts' +assert.ok(process.argv[2], 'Pass a pre-change transcript-reader.ts snapshot.') +const baseline = readFileSync(process.argv[2], 'utf8') +assert.notEqual(baseline, readFileSync(entry, 'utf8'), 'Do not compare the source to itself.') + +async function load(useBaseline) { + const result = await build({ + stdin: { + contents: `export * from './${entry}'; +export { extractAssistantTextFromLine } from './src/shared/agent-hook-listener/transcript-entry-text.ts';`, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent', + plugins: useBaseline + ? [ + { + name: 'baseline-transcript-reader', + setup(builder) { + builder.onLoad({ filter: /transcript-reader\.ts$/ }, () => ({ + contents: baseline, + loader: 'ts' + })) + } + } + ] + : [] + }) + const module = new Module(resolve('transcript-benchmark.cjs')) + module.paths = Module._nodeModulePaths(process.cwd()) + module._compile(result.outputFiles[0].text, module.id) + return module.exports +} + +const versions = [await load(true), await load(false)] +function measure(functions, iterations) { + let sink = 0 + const run = (fn) => { + for (let i = 0; i < iterations; i++) { + sink += fn()?.length ?? 0 + } + } + for (const fn of functions) { + for (let i = 0; i < 3; i++) { + run(fn) + } + } + const samples = [[], []] + for (let round = 0; round < 11; round++) { + for (const index of round % 2 ? [1, 0] : [0, 1]) { + const start = performance.now() + run(functions[index]) + samples[index].push((performance.now() - start) / iterations) + } + } + return { + beforeMs: samples[0].sort((a, b) => a - b)[5], + afterMs: samples[1].sort((a, b) => a - b)[5], + iterations, + sink + } +} + +const cases = [ + ['tiny', `${JSON.stringify({ role: 'assistant', content: 'hello' })}\n`, 10000], + ['64KiB line', `${JSON.stringify({ role: 'assistant', content: 'x'.repeat(65500) })}\n`, 100], + [ + '4MiB line', + `${JSON.stringify({ role: 'assistant', content: 'x'.repeat(4 * 1024 * 1024 - 40) })}\n`, + 10 + ], + [ + '1000 short tool lines', + Array.from({ length: 1000 }, () => + JSON.stringify({ role: 'tool', content: 'x'.repeat(100) }) + ).join('\n'), + 50 + ], + [ + 'Unicode line', + `${JSON.stringify({ role: 'assistant', content: '😀漢字'.repeat(16000) })}\n`, + 100 + ], + [ + 'leading and trailing blank lines', + `\n\r\n${JSON.stringify({ role: 'assistant', content: 'hello' })}\n\n`, + 10000 + ] +] +const directory = mkdtempSync(join(tmpdir(), 'orca-transcript-benchmark-')) +try { + for (const [name, text, iterations] of cases) { + const file = join(directory, 'transcript.jsonl') + writeFileSync(file, text) + const scanners = versions.map( + (v) => () => v.findLastExtractedTranscriptLineText(text, v.extractAssistantTextFromLine) + ) + const readers = versions.map((v) => () => v.readLastAssistantFromTranscriptOnce(file)) + assert.equal(scanners[0](), scanners[1](), name) + assert.equal(readers[0](), readers[1](), name) + console.log( + JSON.stringify({ + name, + bytes: Buffer.byteLength(text), + scanner: measure(scanners, iterations), + warmFileReader: measure(readers, Math.min(iterations, 100)) + }) + ) + } +} finally { + rmSync(directory, { recursive: true, force: true }) +} diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index d3b9db939cd..9243f5a5b78 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -32,6 +32,8 @@ import { import { join, resolve } from 'node:path' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts' import { + ensureWindowsProcessTreeCommandLinePatch, + inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR @@ -89,6 +91,13 @@ function assertPatchApplied() { 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' ) } + if (processCc.includes('OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ')) { + throw new Error( + 'src/process.cc still takes PROCESS_VM_READ for memory or CPU counters it never reads ' + + 'from the address space. pnpm did not apply ' + + 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' + ) + } } // pnpm can materialize this CRLF package without applying its patch. Repair the @@ -123,6 +132,13 @@ function applyWindowsProcessTreeBuildFixes() { '' ) processCc = processCc.replace(/process_count < 1024 && /, '') + // The memory and CPU readers only ever call GetProcessMemoryInfo/GetProcessTimes, + // which need no more than PROCESS_QUERY_LIMITED_INFORMATION; taking VM_READ is + // what EDR scores. + processCc = processCc.replaceAll( + 'OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid)', + 'OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)' + ) if (bindingGyp !== originalBinding) { writeFileSync(bindingPath, bindingGyp) @@ -131,7 +147,8 @@ function applyWindowsProcessTreeBuildFixes() { writeFileSync(processPath, processCc) } stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR) - if (bindingGyp !== originalBinding || processCc !== originalProcess) { + const repairedCommandLine = ensureWindowsProcessTreeCommandLinePatch(PACKAGE_DIR) + if (bindingGyp !== originalBinding || processCc !== originalProcess || repairedCommandLine) { console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.') } } @@ -173,6 +190,14 @@ function main() { if (!existsSync(built)) { throw new Error(`node-gyp reported success but ${built} is missing.`) } + // Why check the artifact and not only the source: the source checks above run + // before node-gyp, and a stale build directory can outlive them. + if (inspectWindowsProcessTreeAddon(built) === 'unpatched') { + throw new Error( + 'The built addon still calls ReadProcessMemory, so it did not come from the patched ' + + 'command-line reader. A relay would get the primitive MDE scores as credential dumping.' + ) + } const machine = readPeMachine(built) if (machine !== PE_MACHINE[arch]) { throw new Error( diff --git a/config/scripts/ci-native-toolchain.test.mjs b/config/scripts/ci-native-toolchain.test.mjs new file mode 100644 index 00000000000..e35437da77c --- /dev/null +++ b/config/scripts/ci-native-toolchain.test.mjs @@ -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 }) + } + }) +}) diff --git a/config/scripts/cli-runtime-client-deferral-equivalence.mjs b/config/scripts/cli-runtime-client-deferral-equivalence.mjs index f443bf3b9f9..a231b5ba75a 100644 --- a/config/scripts/cli-runtime-client-deferral-equivalence.mjs +++ b/config/scripts/cli-runtime-client-deferral-equivalence.mjs @@ -2,7 +2,7 @@ // Equivalence check for deferring the RuntimeClient module graph in the CLI. // // Builds the CLI twice with the REAL tsc emit — once from the working tree and -// once with the seven touched files restored from git HEAD~ (the pre-deferral +// once with the touched files restored from git HEAD~ (the pre-deferral // implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE // across a matrix of invocations. // @@ -13,7 +13,7 @@ // // Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline ] import { execFileSync, spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -21,8 +21,11 @@ const REPO = fileURLToPath(new URL('../..', import.meta.url)) // The files this change touches. Restoring exactly these from the baseline rev // reconstructs the old implementation without disturbing anything else. +// Files absent at the baseline (e.g. cli-error.ts, split out of format.ts +// later) are removed for the baseline build and put back afterwards. const TOUCHED = [ 'src/cli/args.ts', + 'src/cli/cli-error.ts', 'src/cli/dispatch.ts', 'src/cli/flags.ts', 'src/cli/format.ts', @@ -72,12 +75,16 @@ function buildTree(label, baselineRev) { if (baselineRev) { for (const file of TOUCHED) { const path = join(REPO, file) - restored.push([path, readFileSync(path)]) - const old = execFileSync('git', ['show', `${baselineRev}:${file}`], { + restored.push([path, existsSync(path) ? readFileSync(path) : null]) + const old = spawnSync('git', ['show', `${baselineRev}:${file}`], { cwd: REPO, maxBuffer: 64 * 1024 * 1024 }) - writeFileSync(path, old) + if (old.status === 0) { + writeFileSync(path, old.stdout) + } else { + rmSync(path, { force: true }) + } } } execFileSync( @@ -97,7 +104,11 @@ function buildTree(label, baselineRev) { ) } finally { for (const [path, contents] of restored) { - writeFileSync(path, contents) + if (contents === null) { + rmSync(path, { force: true }) + } else { + writeFileSync(path, contents) + } } } return join(outDir, 'cli/index.js') diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index 3412a118491..1732e9a1e8a 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -128,10 +128,14 @@ export async function createDraftRelease({ throw new Error('token is required') } - const previousTag = latestPreviousPublishedDesktopReleaseTag( - await fetchRepoReleases(repo, token, fetchImpl), - tag - ) + const releases = await fetchRepoReleases(repo, token, fetchImpl) + const existingRelease = releases.find((release) => release?.tag_name === tag) + if (existingRelease && existingRelease.draft !== true) { + log(`Release ${tag} already exists and is published.`) + return + } + + const previousTag = latestPreviousPublishedDesktopReleaseTag(releases, tag) const generateNotesBody = { tag_name: tag, target_commitish: tag, @@ -156,24 +160,90 @@ export async function createDraftRelease({ typeof releaseNotes.name === 'string' && releaseNotes.name.length > 0 ? releaseNotes.name : tag const prerelease = tag.includes('-rc.') - // Why: GitHub's generated release notes can exceed the release body API - // limit, so create with a bounded body. Omit target_commitish because the - // release-cut tag already exists and GitHub rejects the tag name there. - await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, { - method: 'POST', - body: JSON.stringify({ - tag_name: tag, - name, - body, - draft: true, - prerelease + if (existingRelease) { + if (!Number.isInteger(existingRelease.id)) { + throw new Error(`Draft release ${tag} is missing a GitHub release id`) + } + // Why: the listing is a snapshot; the draft can be published while notes + // generate, and patching then overwrites a live release body. + const currentRelease = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token + ) + if (currentRelease?.draft !== true) { + log(`Release ${tag} was published while notes were generated; leaving it unchanged.`) + return + } + // Why: the PATCH endpoint supports no conditional/versioned update, so the + // GET above cannot close the window. The PATCH response reports the state we + // actually wrote to; if publication won, put the published body back. + const patchedRelease = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ body }) + } + ) + if (patchedRelease?.draft !== true) { + const publishedBody = typeof currentRelease.body === 'string' ? currentRelease.body : '' + if (publishedBody === body) { + log(`Release ${tag} was published while notes were patched; its body is unchanged.`) + return + } + // Why: the rollback must not clobber a body written after our PATCH, so + // restore only while the release still carries exactly what we wrote. + const releaseBeforeRollback = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token + ) + if (releaseBeforeRollback?.body !== body) { + log( + `Release ${tag} was published and its body changed again while notes were patched; leaving the newer body in place.` + ) + return + } + await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases/${existingRelease.id}`, + token, + { + method: 'PATCH', + body: JSON.stringify({ body: publishedBody }) + } + ) + log( + `Release ${tag} was published while notes were patched; restored its published body and left the generated notes unapplied.` + ) + return + } + } else { + // Why: GitHub's generated release notes can exceed the release body API + // limit, so create with a bounded body. Omit target_commitish because the + // release-cut tag already exists and GitHub rejects the tag name there. + await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases`, token, { + method: 'POST', + body: JSON.stringify({ + tag_name: tag, + name, + body, + draft: true, + prerelease + }) }) - }) + } if (generatedBody.length !== body.length) { - log(`Created draft release ${tag} with truncated generated notes (${body.length} chars).`) + log( + `${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with truncated generated notes (${body.length} chars).` + ) } else { - log(`Created draft release ${tag} with generated notes (${body.length} chars).`) + log( + `${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with generated notes (${body.length} chars).` + ) } } diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index b330ccb9423..911ac00be63 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -132,7 +132,7 @@ describe('createDraftRelease', () => { it('creates a draft release with bounded generated notes', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.35')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -184,7 +184,7 @@ describe('createDraftRelease', () => { it('marks rc tags as prereleases', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')])) + .mockResolvedValueOnce(jsonResponse([release('v1.4.36')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true })) @@ -200,10 +200,136 @@ describe('createDraftRelease', () => { expect(createBody.prerelease).toBe(true) }) + it('regenerates notes for an existing draft release', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'stale' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'notes' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenNthCalledWith( + 3, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.not.objectContaining({ method: expect.anything() }) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 4, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ body: 'notes' }) }) + ) + }) + + it('skips the update when the draft was published while notes were generated', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(fetchImpl).toHaveBeenNthCalledWith( + 3, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.not.objectContaining({ method: expect.anything() }) + ) + }) + + it('restores the published body when publication lands between the check and the patch', async () => { + const log = vi.fn() + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'hand-written notes' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log + }) + + expect(fetchImpl).toHaveBeenCalledTimes(6) + expect(fetchImpl).toHaveBeenNthCalledWith( + 6, + 'https://api.github.com/repos/stablyai/orca/releases/42', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ body: 'hand-written notes' }) + }) + ) + expect(log).toHaveBeenCalledWith(expect.stringContaining('restored its published body')) + }) + + it('leaves a body written after the patch in place instead of rolling it back', async () => { + const log = vi.fn() + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })]) + ) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'newer published body' })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log + }) + + expect(fetchImpl).toHaveBeenCalledTimes(5) + expect(log).toHaveBeenCalledWith(expect.stringContaining('leaving the newer body in place')) + }) + + it('preserves notes on an existing published release', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([release('v1.4.36', { id: 42 })])) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => { const fetchImpl = vi .fn() - .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')])) + .mockResolvedValueOnce(jsonResponse([release('mobile-v0.0.12')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs index 7ae3b1c9428..58f6f8d8865 100644 --- a/config/scripts/electron-builder-markdown-associations.test.mjs +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -103,14 +103,24 @@ describe('electron-builder markdown file associations', () => { // Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown // hooks too. electron-builder allows only one include, so a merge that drops the daemon - // sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall. + // sweep would silently orphan a running daemon host on every uninstall. + // + // Asserted against comment-stripped script, and on the app exe name first: the relocated + // host is a verbatim copy of the app exe (daemonHostExeName, daemon-host-relocation.ts), + // so a macro that kills only orca-terminal-daemon.exe matches no running process. The + // prose above the macro names both, so a toContain over the raw file proves nothing. it('keeps the daemon-host uninstall sweep across the include rename', async () => { - const hooks = await readInstallerHooks() + const script = stripNsisCommentLines(await readInstallerHooks()) - expect(hooks).toContain('orca-terminal-daemon.exe') - expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host') + expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?\$\{APP_EXECUTABLE_FILENAME\}"?/) + // Legacy name, so hosts left by builds that renamed the copy still get reaped. + expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?orca-terminal-daemon\.exe"?/) + // Scopes both kills to the uninstalling user: an elevated machine-wide uninstall must + // not reach another logged-on user's session. + expect(script).toMatch(/\/FI\s+"USERNAME eq /) + expect(script).toContain('$LOCALAPPDATA\\Orca\\daemon-host') // Without this guard, uninstallOldVersion would kill the daemon on every update — // defeating the relocation that keeps terminals alive across updates. - expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) + expect(script).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) }) }) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index a4cc6db8843..b2a47b99d5b 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -5,6 +5,12 @@ import { createRequire } from 'node:module' import { existsSync, readFileSync } from 'node:fs' import { release } from 'node:os' import { basename, dirname, resolve } from 'node:path' +import { + ensureWindowsProcessTreeCommandLinePatch, + inspectWindowsProcessTreeAddon, + stageWindowsProcessTreeNodeAddonApiHeaders, + windowsProcessTreeAddonPath +} from './windows-process-tree-gyp-rebuild.mjs' const require = createRequire(import.meta.url) const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') @@ -253,11 +259,18 @@ function collectNativeModuleFailures() { function loadNativeModule(moduleName) { if (moduleName === '@vscode/windows-process-tree') { - // A bare require already loads the .node addon on win32, so it catches an - // ABI mismatch on its own. What it cannot catch is a snapshot that comes - // back empty -- the shape a blocked CreateToolhelp32Snapshot produces -- - // so check the addon actually enumerates before calling the runtime healthy. + // A bare require loads the .node addon on win32, so it catches an ABI + // mismatch on its own. What it cannot catch is *which* addon loaded: the + // published tarball ships a prebuilt built from unpatched source that is + // node-addon-api, so it requires cleanly and then reads every process's + // command line out of its address space. Check the binary, not the load. require(moduleName) + if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath()) === 'unpatched') { + throw new Error( + 'the loaded addon still calls ReadProcessMemory, so it was not built from the patched ' + + 'source. Rebuild it (pnpm run rebuild:electron) rather than using the published prebuild.' + ) + } return } if (moduleName === 'windows-native-registry') { @@ -368,6 +381,14 @@ function getWindowsBuildNumber() { function rebuildNodeRuntimeModules(moduleNames) { for (const moduleName of moduleNames) { const moduleDir = dirname(require.resolve(`${moduleName}/package.json`)) + if (moduleName === '@vscode/windows-process-tree') { + // Why before node-gyp: this module is rebuilt precisely because the + // binary was the unpatched one, and pnpm materializes it unpatched often + // enough that compiling the source as-is would just rebuild the same + // reader and fail the verify pass. + ensureWindowsProcessTreeCommandLinePatch(moduleDir) + stageWindowsProcessTreeNodeAddonApiHeaders(moduleDir) + } console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`) runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir }) if (moduleName === 'node-pty' && process.platform === 'win32') { diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index ea6e876e619..973e2f6852d 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { copyScriptWithLocalModules } from './script-module-dependencies.mjs' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) const sourceNodePtyJobOwnershipPath = fileURLToPath( @@ -27,7 +28,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeFakeNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) @@ -67,7 +67,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true }) writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) @@ -102,7 +101,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) @@ -137,7 +135,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeLoadableNativeModules(projectDir) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) @@ -171,7 +168,6 @@ describe('ensure-native-runtime', () => { const logPath = join(projectDir, 'native-runtime.log') const markerPath = join(projectDir, 'rebuilt.marker') const binDir = join(projectDir, 'bin') - copyFileSync(sourceScriptPath, scriptPath) writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' }) writeNodePtyPatchFile(projectDir) writePatchedNodePtyBuildArtifacts(projectDir) @@ -198,7 +194,9 @@ describe('ensure-native-runtime', () => { function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) - mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + // Walked, not listed: the script imports windows-process-tree-gyp-rebuild.mjs, and a fixture + // missing it fails every case with a module-resolution error instead of the defect under test. + copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts')) copyFileSync( sourceNodePtyJobOwnershipPath, join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') diff --git a/config/scripts/file-explorer-deletion-roots-benchmark.mjs b/config/scripts/file-explorer-deletion-roots-benchmark.mjs new file mode 100644 index 00000000000..d276964861b --- /dev/null +++ b/config/scripts/file-explorer-deletion-roots-benchmark.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' + +const root = fileURLToPath(new URL('../..', import.meta.url)) +const bundled = await build({ + stdin: { + contents: `export { selectDeletionRoots } from './file-explorer-batch-deletion'; + export { isPathEqualOrDescendant } from './file-explorer-paths';`, + resolveDir: join(root, 'src/renderer/src/components/right-sidebar'), + loader: 'ts' + }, + alias: { '@': join(root, 'src/renderer/src') }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent' +}) +const { selectDeletionRoots, isPathEqualOrDescendant } = await import( + `data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}` +) + +// Original production selector; both paths use the same path-comparison implementation. +function original(nodes) { + return nodes.filter( + (n) => + !nodes.some( + (other) => other !== n && other.isDirectory && isPathEqualOrDescendant(n.path, other.path) + ) + ) +} + +function measure(run, nodes) { + for (let index = 0; index < 3; index++) { + run(nodes) + } + const samples = [] + for (let index = 0; index < 11; index++) { + const start = performance.now() + run(nodes) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[5] +} + +const results = [] +for (const [fileCount, directoryCount] of [ + [100, 0], + [1000, 0], + [5000, 0], + [5000, 5], + [0, 100] +]) { + const nodes = Array.from({ length: fileCount + directoryCount }, (_, index) => ({ + name: `item-${index}`, + path: `/repo/item-${index}`, + relativePath: `item-${index}`, + isDirectory: index >= fileCount, + depth: 0 + })) + const expected = original(nodes) + const actual = selectDeletionRoots(nodes) + assert.equal(actual.length, expected.length) + actual.forEach((node, index) => assert.equal(node, expected[index])) + results.push({ + fileCount, + directoryCount, + beforeMs: measure(original, nodes), + afterMs: measure(selectDeletionRoots, nodes) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/hourly-preflight-workflow.test.mjs b/config/scripts/hourly-preflight-workflow.test.mjs new file mode 100644 index 00000000000..2bec40b329b --- /dev/null +++ b/config/scripts/hourly-preflight-workflow.test.mjs @@ -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) + }) +}) diff --git a/config/scripts/mobile-file-ranking-benchmark.mjs b/config/scripts/mobile-file-ranking-benchmark.mjs new file mode 100644 index 00000000000..68ac5b9d977 --- /dev/null +++ b/config/scripts/mobile-file-ranking-benchmark.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' + +const baseline = process.argv[2] +if (!baseline) { + throw new Error('Usage: node config/scripts/mobile-file-ranking-benchmark.mjs ') +} +async function load(source) { + const js = stripTypeScriptTypes(source, { mode: 'transform' }) + return await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`) +} +function measure(fn, paths, query) { + for (let warmup = 0; warmup < 10; warmup++) { + fn(paths, query, 16) + } + const samples = [] + for (let i = 0; i < 9; i++) { + const start = performance.now() + fn(paths, query, 16) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[4] +} +const results = [] +for (const [file, name] of [ + ['src/main/runtime/runtime-mobile-file-path-search.ts', 'rankRuntimeMobileFilePaths'], + ['mobile/src/session/mobile-native-chat-autocomplete.ts', 'rankSuggestions'] +]) { + const before = ( + await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })) + )[name] + const after = (await load(readFileSync(file, 'utf8')))[name] + for (const count of [100, 100000]) { + const paths = Array.from( + { length: count }, + (_, i) => `src/components/workspace/group-${i % 100}/file-${i}.tsx` + ) + for (const query of ['file-9', 'missing', 'workspace']) { + assert.deepEqual(after(paths, query, 16), before(paths, query, 16)) + results.push({ + function: name, + paths: count, + query, + beforeMs: measure(before, paths, query), + afterMs: measure(after, paths, query) + }) + } + } +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/mobile-markdown-placeholder-benchmark.mjs b/config/scripts/mobile-markdown-placeholder-benchmark.mjs new file mode 100644 index 00000000000..20280e5a8d2 --- /dev/null +++ b/config/scripts/mobile-markdown-placeholder-benchmark.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const sourcePath = 'mobile/src/components/mobile-markdown-preview-html.ts' +const baselineRef = process.argv[2] +if (!baselineRef) { + throw new Error( + 'Usage: node config/scripts/mobile-markdown-placeholder-benchmark.mjs ' + ) +} +async function load(source) { + const result = await build({ + stdin: { contents: source, resolveDir: dirname(resolve(sourcePath)), loader: 'ts' }, + bundle: true, + write: false, + platform: 'node', + format: 'esm' + }) + return ( + await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) + ).normalizeMobileMarkdownPreviewHtml +} +const before = await load( + execFileSync('git', ['show', `${baselineRef}:${sourcePath}`], { encoding: 'utf8' }) +) +const after = await load(readFileSync(sourcePath, 'utf8')) +function measure(fn, input, repeats) { + const samples = [] + for (let run = 0; run < repeats; run++) { + const start = performance.now() + fn(input) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)] +} +const results = [] +for (const [shape, input] of [ + ['ordinary Markdown', '# Hello\n\n

Use `Array` and bold.

'], + ...[2048, 8192, 16384].map((length) => [ + `${length} underscore collision`, + `\uE000ORCA_MD_CODE_${'_'.repeat(length)}0\uE000 and \`Array\`` + ]) +]) { + assert.equal(after(input), before(input)) + results.push({ + shape, + bytes: Buffer.byteLength(input), + beforeMs: measure(before, input, 5), + afterMs: measure(after, input, 15) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/patched-dependencies-frozen-install.test.mjs b/config/scripts/patched-dependencies-frozen-install.test.mjs new file mode 100644 index 00000000000..89f98ef074b --- /dev/null +++ b/config/scripts/patched-dependencies-frozen-install.test.mjs @@ -0,0 +1,155 @@ +import { + cpSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join, parse, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcessSync } from '../../src/shared/child-process/run-process.ts' +import { resolveCliCommand } from '../../src/shared/node-cli-command-resolution.ts' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' + +/** + * Run the command that actually consumes the patch hashes. + * + * A hash comparison is not this check. `@vscode/windows-process-tree@0.8.0` shipped + * twice with a hand-computed `sha256(patchBytes)` in the lockfile, and two separate + * reviews "verified" it by recomputing the same number the same wrong way. pnpm + * hashes the **LF-normalized** content, so a CRLF patch makes the raw digest a value + * pnpm will never produce, and `--frozen-lockfile` dies with + * ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on every runner. An independent check that + * repeats the original assumption is not independent; only the installer is. + * + * `--lockfile-only --ignore-scripts` keeps it to the resolution pnpm rejects on, + * with no node_modules and no native builds. + */ +const PROJECT_DIR = resolve(import.meta.dirname, '../..') +const WINDOWS_PROCESS_TREE_PATCH = '@vscode__windows-process-tree@0.8.0.patch' + +/** + * Which pnpm to run belongs to pnpm-cli-invocation.mjs, not to this file: naming + * the Windows shim here is what windows-cmd-shim-spawn-boundary.test.mjs rejects. + * Its `shell` is dropped on purpose -- runProcessSync refuses that flag and + * already drives a shim through the interpreter itself. + */ +function resolvePnpmInvocation() { + const { command, prefixArgs } = resolvePnpmCliInvocation() + if (isAbsolute(command)) { + return existsSync(command) ? { program: command, prefixArgs } : null + } + // Bare name only when npm_execpath is unset (bare `vitest`, not `pnpm test`). + // Drop the extension so the shared resolver tries every executable form of it. + const resolved = resolveCliCommand(parse(command).name) + return isAbsolute(resolved) ? { program: resolved, prefixArgs } : null +} + +describe('patched dependencies', () => { + it('installs with --frozen-lockfile, which is what validates every patch hash', () => { + const pnpm = resolvePnpmInvocation() + expect(pnpm, 'pnpm must be installed; it is the only thing that can check this').not.toBeNull() + + // A copy, because a --frozen-lockfile run still rewrites parts of the + // lockfile this repo does not track, and the real one must not move. + const scratch = mkdtempSync(join(tmpdir(), 'orca-frozen-install-')) + try { + for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) { + copyFileSync(join(PROJECT_DIR, file), join(scratch, file)) + } + mkdirSync(join(scratch, 'config'), { recursive: true }) + cpSync(join(PROJECT_DIR, 'config', 'patches'), join(scratch, 'config', 'patches'), { + recursive: true + }) + + const result = runProcessSync({ + program: pnpm.program, + args: [ + ...pnpm.prefixArgs, + 'install', + '--frozen-lockfile', + '--lockfile-only', + '--ignore-scripts' + ], + cwd: scratch, + timeoutMs: 300_000 + }) + + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0) + } finally { + removeTreeSync(scratch) + } + // The 300s spawn budget is only reachable if the case is allowed to take it; + // config/vitest.config.ts caps every case at 30s by default. + }, 300_000) + + /** + * `--lockfile-only` resolves; it never applies a patch. So the case above is + * bounded to hash consistency, and the actual question -- can pnpm still put + * the patched reader on disk? -- had nothing covering it. + * + * One package, patch applied for real, assert the marker landed. Scoped to the + * single dependency so it stays a ~2s check rather than a full install. + */ + it('materializes the patched command-line reader on a real install', () => { + const pnpm = resolvePnpmInvocation() + expect(pnpm, 'pnpm must be installed; it is the only thing that can check this').not.toBeNull() + + const scratch = mkdtempSync(join(tmpdir(), 'orca-patch-apply-')) + try { + mkdirSync(join(scratch, 'config', 'patches'), { recursive: true }) + copyFileSync( + join(PROJECT_DIR, 'config', 'patches', WINDOWS_PROCESS_TREE_PATCH), + join(scratch, 'config', 'patches', WINDOWS_PROCESS_TREE_PATCH) + ) + writeFileSync( + join(scratch, 'package.json'), + `${JSON.stringify( + { + name: 'orca-patch-apply-probe', + version: '1.0.0', + dependencies: { '@vscode/windows-process-tree': '0.8.0' } + }, + null, + 2 + )}\n` + ) + writeFileSync( + join(scratch, 'pnpm-workspace.yaml'), + 'packages: []\n' + + 'patchedDependencies:\n' + + ` '@vscode/windows-process-tree@0.8.0': config/patches/${WINDOWS_PROCESS_TREE_PATCH}\n` + ) + + const result = runProcessSync({ + program: pnpm.program, + args: [...pnpm.prefixArgs, 'install', '--no-frozen-lockfile', '--ignore-scripts'], + cwd: scratch, + timeoutMs: 300_000 + }) + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0) + + const materialized = readFileSync( + join( + scratch, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'src', + 'process_commandline.cc' + ), + 'utf8' + ) + expect(materialized).toContain('kProcessCommandLineInformation') + // The whole point of the patch: the upstream reader is gone, not merely + // supplemented. + expect(materialized).not.toContain('ReadProcessMemory') + } finally { + removeTreeSync(scratch) + } + }, 300_000) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 15ded915c67..3089d376b2a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -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 = [ diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 4642372135c..f31822e5b93 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -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', () => { diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index ceac6b8cc6e..41f9338ab75 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -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) } }) diff --git a/config/scripts/pr-e2e-native-only-routing.test.mjs b/config/scripts/pr-e2e-native-only-routing.test.mjs new file mode 100644 index 00000000000..b6c3662cd1d --- /dev/null +++ b/config/scripts/pr-e2e-native-only-routing.test.mjs @@ -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') + }) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index efd0fbd6526..fbde35b808f 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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 { diff --git a/config/scripts/quick-open-exclusion-benchmark.mjs b/config/scripts/quick-open-exclusion-benchmark.mjs new file mode 100644 index 00000000000..399302c5a2b --- /dev/null +++ b/config/scripts/quick-open-exclusion-benchmark.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' + +const bundled = await build({ + entryPoints: ['src/shared/quick-open-filter.ts'], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent' +}) +const { shouldExcludeQuickOpenRelPath: after } = await import( + `data:text/javascript;base64,${Buffer.from(bundled.outputFiles[0].text).toString('base64')}` +) +// Original production predicate, including its exact boundary check. +function before(relPath, prefixes) { + for (const prefix of prefixes) { + if (relPath === prefix) { + return true + } + if (relPath.length > prefix.length && relPath.startsWith(`${prefix}/`)) { + return true + } + } + return false +} +const files = Array.from( + { length: 100000 }, + (_, index) => `src/components/group-${index % 100}/file-${index}.tsx` +) +function run(fn, prefixes) { + let excluded = 0 + for (const file of files) { + excluded += Number(fn(file, prefixes)) + } + return excluded +} +function measure(fn, prefixes) { + run(fn, prefixes) + const samples = [] + for (let index = 0; index < 5; index++) { + const start = performance.now() + run(fn, prefixes) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[2] +} +const results = [] +for (const count of [0, 10, 100, 500]) { + const prefixes = Array.from({ length: count }, (_, index) => `nested-worktrees/worktree-${index}`) + assert.equal(run(after, prefixes), run(before, prefixes)) + results.push({ + files: files.length, + exclusions: count, + beforeMs: measure(before, prefixes), + afterMs: measure(after, prefixes) + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2)) diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index 09e38853371..871732dd53d 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -4,6 +4,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + gitLineEndingEnv, + initGitWorkTree, mkTempProject, runRebuildScript, writeFakeElectronRebuild, @@ -14,7 +16,8 @@ import { writeFakeWindowsProcessTreeWithNodeAddonApi, writeFakeWindowsRegistry, writeNodePtyPatchFile, - writePatchedNodePtyBuildArtifacts + writePatchedNodePtyBuildArtifacts, + writeWindowsProcessTreePatchFile } from './rebuild-native-deps-test-fixtures.mjs' describe('rebuild-native-deps patched node-pty rebuild', () => { @@ -85,6 +88,91 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } }) + const commandLineSourcePath = (projectDir) => + join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'src', + 'process_commandline.cc' + ) + + // Why inside a git work tree: `git apply` run under one prefixes patch paths + // with the cwd-relative prefix, silently skips what does not match, and still + // exits 0. The package dir is always under the project root in production, so + // a fixture in %TEMP% alone would pass while the real repair did nothing. + // + // Why both line-ending modes: the patch is stored LF while upstream ships this + // source CRLF, so whether the pre-image matches depends on `core.autocrlf` -- + // and under `false`, Git's own built-in default, it did not. The repair blinds + // git to the repo, so that value comes from global config, i.e. from whichever + // option the developer's installer wrote. Pinning both makes the case cover the + // host that breaks rather than the host that happens to run it. + for (const autocrlf of ['false', 'true']) { + it(`repairs an un-applied command-line patch in a work tree (autocrlf=${autocrlf})`, () => { + const projectDir = mkTempProject() + + try { + initGitWorkTree(projectDir) + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { + commandLinePatchApplied: false + }) + writeWindowsProcessTreePatchFile(projectDir) + + const result = runRebuildScript( + projectDir, + { + npm_config_platform: 'win32', + npm_config_arch: 'x64', + ...gitLineEndingEnv(autocrlf) + }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).toContain( + 'kProcessCommandLineInformation' + ) + } finally { + removeTreeSync(projectDir) + } + }) + } + + // Why fail rather than build: an unpatched command-line reader compiles fine + // and then opens every process with PROCESS_VM_READ to walk its PEB, which is + // the primitive the patch exists to remove. + it('refuses a Windows rebuild when the command-line patch cannot be applied', () => { + const projectDir = mkTempProject() + + try { + initGitWorkTree(projectDir) + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { commandLinePatchApplied: false }) + // No patch file, so the repair has nothing to apply. + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('process_commandline.cc') + expect(readFileSync(commandLineSourcePath(projectDir), 'utf8')).not.toContain( + 'kProcessCommandLineInformation' + ) + } finally { + removeTreeSync(projectDir) + } + }) + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { const projectDir = mkTempProject() @@ -256,4 +344,37 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } } ) + + // The binary this step produces is the one copied into the packaged app. The + // relay build checks its own artifact and ensure-native-runtime checks what it + // loads; nothing checked this one, so a rebuild that quietly emitted the + // upstream reader shipped. Both non-clean states have to fail, which is the + // caller the tri-state was missing: after a rebuild that reported success, an + // absent binary is a broken build, not an absence to shrug at. + for (const [addon, expected] of [ + ['unpatched', 'still imports ReadProcessMemory'], + ['none', 'is not there'] + ]) { + it(`fails a Windows rebuild that leaves ${addon} windows-process-tree bytes`, () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { addon }) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(expected) + } finally { + removeTreeSync(projectDir) + } + }) + } }) diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 585e7a58ef2..2cb7d8ba8b4 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -1,5 +1,12 @@ import { spawnSync } from 'node:child_process' -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,6 +22,68 @@ const sourceNodePtyJobOwnershipPath = fileURLToPath( const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath( new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url) ) +const sourceWindowsProcessTreePatchPath = fileURLToPath( + new URL('../patches/@vscode__windows-process-tree@0.8.0.patch', import.meta.url) +) + +/** + * The command-line reader as it is *before* the patch, taken from the patch's + * own pre-image so no upstream copy has to be vendored. + * + * Written back as **CRLF**, which is what `@vscode/windows-process-tree@0.8.0` + * actually ships: all 67 pre-image lines of this file carried a CR before the + * patch was normalized to LF. Rebuilding it with the patch's current newline + * instead would make fixture and patch agree by construction, on any encoding — + * which is exactly how a repair that cannot apply to the real package passed + * this suite. + */ +function unpatchedWindowsProcessTreeCommandLineSource() { + const lines = readFileSync(sourceWindowsProcessTreePatchPath, 'utf8').split('\n') + const start = lines.findIndex((line) => + line.startsWith('diff --git a/src/process_commandline.cc ') + ) + const rest = lines.slice(start + 1) + const end = rest.findIndex((line) => line.startsWith('diff --git ')) + const preImage = (end === -1 ? rest : rest.slice(0, end)) + .filter((line) => line.startsWith(' ') || line.startsWith('-')) + .filter((line) => !line.startsWith('---')) + .map((line) => line.slice(1).replace(/\r$/, '')) + .join('\r\n') + // Splitting drops the file's own trailing newline as an empty element, and + // `git apply` needs the bytes exact. + return `${preImage}\r\n` +} + +/** + * Pin `core.autocrlf` for a spawned repair, whatever the host is set to. + * + * The repair blinds git to the surrounding repo with `GIT_DIR`, so the value it + * sees comes from global/system config — on a Git for Windows box that is + * whichever line-ending option the installer wrote, and `false` (Git's built-in + * default, "checkout as-is") is the one the repair used to fail under. A global + * config in a temp HOME outranks the system file, so this is deterministic + * rather than whatever the developer happens to have. + */ +export function gitLineEndingEnv(autocrlf) { + const home = mkdtempSync(join(tmpdir(), `orca-git-home-${autocrlf}-`)) + writeFileSync(join(home, '.gitconfig'), `[core]\n\tautocrlf = ${autocrlf}\n`) + return { HOME: home, USERPROFILE: home } +} + +/** Production always runs the repair from inside a work tree; `git apply` behaves differently there. */ +export function initGitWorkTree(projectDir) { + for (const args of [['init'], ['config', 'user.email', 'a@b.c'], ['config', 'user.name', 't']]) { + spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' }) + } +} + +export function writeWindowsProcessTreePatchFile(projectDir) { + mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) + copyFileSync( + sourceWindowsProcessTreePatchPath, + join(projectDir, 'config', 'patches', '@vscode__windows-process-tree@0.8.0.patch') + ) +} export function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) @@ -143,17 +212,46 @@ if (${JSON.stringify(createExecutable)}) { ) } -export function writeFakeElectronRebuild(projectDir, { logPathEnv = null } = {}) { +/** Bytes that stand in for a compiled addon's import table. */ +const FAKE_ADDON_BYTES = { + clean: 'MZ\0ntdll.dll\0NtQueryInformationProcess\0', + unpatched: 'MZ\0KERNEL32.dll\0ReadProcessMemory\0' +} + +/** + * A rebuild that produces nothing leaves no addon to inspect, and the script now + * asserts the binary it just built is a patched one. Emit a stand-in so the + * fixture models a rebuild that actually succeeded. `addon` picks which kind, + * because "produced the upstream reader" and "produced nothing" are both real + * outcomes that assertion has to tell apart. + */ +export function writeFakeElectronRebuild(projectDir, { logPathEnv = null, addon = 'clean' } = {}) { const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild') mkdirSync(rebuildDir, { recursive: true }) writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' })) + const emitAddon = + addon === 'none' + ? '' + : ` + const packageDir = join('node_modules', '@vscode', 'windows-process-tree') + if (existsSync(join(packageDir, 'package.json'))) { + mkdirSync(join(packageDir, 'build', 'Release'), { recursive: true }) + writeFileSync( + join(packageDir, 'build', 'Release', 'windows_process_tree.node'), + ${JSON.stringify(FAKE_ADDON_BYTES[addon])} + ) + }` + const emitImports = + addon === 'none' + ? '' + : "import { existsSync, mkdirSync, writeFileSync } from 'node:fs'\nimport { join } from 'node:path'\n" writeFileSync( join(rebuildDir, 'index.js'), logPathEnv ? ` import { appendFileSync } from 'node:fs' - -export async function rebuild(options) { +${emitImports} +export async function rebuild(options) {${emitAddon} const logPath = process.env[${JSON.stringify(logPathEnv)}] if (!logPath) { return @@ -171,7 +269,10 @@ export async function rebuild(options) { ) } ` - : 'export async function rebuild() {}\n' + : `${emitImports} +export async function rebuild() {${emitAddon} +} +` ) } @@ -271,12 +372,22 @@ export function writeFakeWindowsProcessTree(projectDir) { writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') } -export function writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) { +export function writeFakeWindowsProcessTreeWithNodeAddonApi( + projectDir, + { commandLinePatchApplied = true } = {} +) { const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api') mkdirSync(nodeAddonApiDir, { recursive: true }) writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n') writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + mkdirSync(join(processTreeDir, 'src'), { recursive: true }) + writeFileSync( + join(processTreeDir, 'src', 'process_commandline.cc'), + commandLinePatchApplied + ? '// kProcessCommandLineInformation = 60\n' + : unpatchedWindowsProcessTreeCommandLineSource() + ) writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n') writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n') writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') diff --git a/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs b/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs new file mode 100644 index 00000000000..4f98c1b092d --- /dev/null +++ b/config/scripts/rebuild-native-deps-windows-process-tree.test.mjs @@ -0,0 +1,103 @@ +import { spawn } from 'node:child_process' +import { appendFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts' + +import { + mkTempProject, + runRebuildScript, + writeFakeElectronRebuild, + writeFakeNodePtyConptyPayload, + writeFakeUsableElectronPackage, + writeFakeWindowsProcessTreeWithNodeAddonApi +} from './rebuild-native-deps-test-fixtures.mjs' + +const require = createRequire(import.meta.url) + +/** A real loadable addon, so the OS holds the same lock a running Orca holds. */ +function repoAddonPath() { + try { + const entry = require.resolve('@vscode/windows-process-tree') + const built = join(entry, '..', '..', 'build', 'Release', 'windows_process_tree.node') + return existsSync(built) ? built : null + } catch { + return null + } +} + +/** + * Stage a stale addon and keep it loaded, exactly as a running Orca does. + * + * The bytes are the repo's own patched build with the flagged import appended, + * because the guard keys on that symbol and the patched binary does not carry + * it. Trailing bytes are PE overlay, so the file still loads. + */ +async function stageLoadedStaleAddon(projectDir) { + const source = repoAddonPath() + const releaseDir = join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'build', + 'Release' + ) + mkdirSync(releaseDir, { recursive: true }) + const stale = join(releaseDir, 'windows_process_tree.node') + copyFileSync(source, stale) + appendFileSync(stale, 'ReadProcessMemory') + + const holder = spawn( + process.execPath, + ['-e', 'require(process.argv[1]); process.send("held"); setInterval(() => {}, 1000)', stale], + { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] } + ) + await new Promise((resolve, reject) => { + holder.once('message', resolve) + holder.once('exit', () => reject(new Error('the addon holder exited before loading'))) + }) + return holder +} + +// Why an end-to-end run: the defect was purely one of placement. The guard threw +// a real EPERM, and the classifier that turns that into "close running Orca" +// already existed -- the throw simply happened before the try that reaches it. +// Only the whole script exercises that. +describe.runIf(process.platform === 'win32')('rebuild-native-deps stale addon under lock', () => { + it.skipIf(!repoAddonPath())( + 'reports a locked stale addon as a Windows file lock instead of an EPERM stack', + async () => { + const projectDir = mkTempProject() + let holder + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + holder = await stageLoadedStaleAddon(projectDir) + + const result = runRebuildScript( + projectDir, + { + npm_lifecycle_event: 'postinstall', + npm_config_platform: 'win32', + npm_config_arch: process.arch + }, + ['--platform=win32', `--arch=${process.arch}`, '--force'] + ) + + expect(result.stderr).toContain( + 'Close running Orca/Electron/dev processes for this worktree' + ) + // Non-strict postinstall soft-exits on a lock; the next dev/start re-checks. + expect(result.status, result.stderr).toBe(0) + } finally { + holder?.kill() + removeTreeSync(projectDir) + } + } + ) +}) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 3b17683e831..863aac850a1 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -20,7 +20,12 @@ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' -import { stageWindowsProcessTreeNodeAddonApiHeaders } from './windows-process-tree-gyp-rebuild.mjs' +import { + ensureWindowsProcessTreeCommandLinePatch, + inspectWindowsProcessTreeAddon, + stageWindowsProcessTreeNodeAddonApiHeaders, + windowsProcessTreeAddonPath +} from './windows-process-tree-gyp-rebuild.mjs' import { copyFileSync, existsSync, @@ -141,15 +146,21 @@ if (!ignoreModules.includes('cpu-features')) { } } -if ( - rebuildPlatform === 'win32' && - modulesToRebuild.includes('@vscode/windows-process-tree') && - existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')) -) { - stageWindowsProcessTreeNodeAddonApiHeaders() -} - try { + // Why inside the try: the patch guard deletes a stale addon binary, and that + // delete fails EPERM when the addon is loaded -- exactly the running-Orca case + // the catch below is written for. Outside, it aborted `pnpm install` with a + // raw stack instead of the "close running Orca/Electron processes" message. + if ( + rebuildPlatform === 'win32' && + modulesToRebuild.includes('@vscode/windows-process-tree') && + existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')) + ) { + stageWindowsProcessTreeNodeAddonApiHeaders() + if (ensureWindowsProcessTreeCommandLinePatch()) { + console.warn('[rebuild] Repaired the un-applied windows-process-tree command-line patch.') + } + } await rebuild({ buildPath: projectDir, electronVersion, @@ -165,6 +176,7 @@ try { force: true }) restoreNodePtyWindowsConptyRuntime() + assertWindowsProcessTreeAddonIsPatched() } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) if (isWindowsNativeLockError(err)) { @@ -184,6 +196,40 @@ try { process.exit(1) } +/** + * The binary this rebuild just produced is the one the packaged app ships. + * + * The relay build asserts its own artifact and `ensure-native-runtime.mjs` + * asserts what it loads, but nothing checked the addon that gets copied into the + * packaged `node_modules` -- so a rebuild that silently produced the upstream + * reader would reach users. Anything but `clean` fails: after a rebuild that + * reported success the binary must exist, so `missing` is a broken build, not an + * absence to shrug at. This is the caller that needs the state to be a state and + * not a boolean. + */ +function assertWindowsProcessTreeAddonIsPatched() { + if ( + rebuildPlatform !== 'win32' || + !modulesToRebuild.includes('@vscode/windows-process-tree') || + !existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')) + ) { + return + } + const addonPath = windowsProcessTreeAddonPath() + const state = inspectWindowsProcessTreeAddon(addonPath) + if (state === 'clean') { + return + } + throw new Error( + state === 'missing' + ? `the rebuild reported success but ${addonPath} is not there, so the packaged app would ` + + 'ship no windows-process-tree addon at all.' + : `${addonPath} still imports ReadProcessMemory, so it was not built from the patched ` + + 'command-line reader. The packaged app would carry the primitive MDE scores as ' + + 'credential dumping.' + ) +} + function restoreNodePtyWindowsConptyRuntime() { if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) { return diff --git a/config/scripts/redactor-environment-lines-benchmark.mjs b/config/scripts/redactor-environment-lines-benchmark.mjs new file mode 100644 index 00000000000..71aebf9fe88 --- /dev/null +++ b/config/scripts/redactor-environment-lines-benchmark.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' +import { redactString } from '../../src/main/observability/redactor.ts' + +// Supply an unchanged redactor.ts snapshot to measure the actual previous production function. +const baselinePath = process.argv[2] +if (!baselinePath) { + throw new Error( + 'Usage: node config/scripts/redactor-environment-lines-benchmark.mjs ' + ) +} +const baselineSource = stripTypeScriptTypes(readFileSync(baselinePath, 'utf8')) +const { redactString: before } = await import( + `data:text/javascript;base64,${Buffer.from(baselineSource).toString('base64')}` +) +function median(fn, input, repeats) { + const samples = [] + for (let run = 0; run < repeats; run++) { + const started = performance.now() + fn(input) + samples.push(performance.now() - started) + } + return samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)] +} +const rows = [] +for (const [shape, input] of [ + ['8KiB blank lines', '\n'.repeat(8192)], + ['16KiB blank lines', '\n'.repeat(16384)], + ['32KiB blank lines', '\n'.repeat(32768)], + ['32KiB blank lines then invalid key', `${'\n'.repeat(32768)}lowercase`], + ['ordinary env', 'FOO=value\nBAR=other\n'], + ['ordinary message', 'Cannot read directory /workspace/source: file not found'] +]) { + assert.equal(redactString(input), before(input)) + const beforeMs = median(before, input, 3) + const afterMs = median(redactString, input, 15) + rows.push({ + shape, + bytes: Buffer.byteLength(input), + beforeMs, + afterMs, + speedup: beforeMs / afterMs + }) +} +console.log(JSON.stringify({ node: process.version, platform: process.platform, rows }, null, 2)) diff --git a/config/scripts/relay-asset-line-ending-pin.test.mjs b/config/scripts/relay-asset-line-ending-pin.test.mjs new file mode 100644 index 00000000000..3384aa9b88a --- /dev/null +++ b/config/scripts/relay-asset-line-ending-pin.test.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { RELAY_ARTIFACTS } from '../../src/shared/relay-artifacts.ts' +import { describe, expect, it } from 'vitest' + +/** + * Guard the `.gitattributes` pin that keeps `config/relay-assets` on LF. + * + * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a + * pin a Windows runner checks these out as CRLF. build-relay.mjs copies them + * verbatim into the bundle and hashes them byte-for-byte into `.version`, which + * names the immutable remote relay directory -- so a Windows-built client and a + * mac/Linux-built one disagree on the same release, and one SSH host ends up with + * two relay trees, each paying its own remote native-dep compile. + * + * Measured on v1.4.197: master-cloexec-patch.cjs shipped at 11229 bytes from the + * mac runner and 11547 (= 11229 + 318 lines) from the Windows one. + */ +const projectDir = resolve(import.meta.dirname, '../..') + +function git(args) { + return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' }) +} + +/** `git check-attr -z` emits NUL-separated path/attr/value triples. */ +function eolAttributes(paths) { + const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0') + const found = new Map() + for (let index = 0; index + 2 < fields.length; index += 3) { + found.set(fields[index], fields[index + 2]) + } + return found +} + +/** + * Keyed off the manifest, not a directory: build-relay refuses to emit an + * artifact absent from RELAY_ARTIFACTS, so relocating an asset cannot slip + * past this the way a path glob would. esbuild bundles have no tracked + * source and contribute no hits, so they need no classifying. + */ +function trackedManifestSources() { + const paths = new Set() + for (const { filename } of RELAY_ARTIFACTS) { + const hits = git(['ls-files', '-z', '--', `*/${filename}`]) + .split('\0') + .filter(Boolean) + for (const path of hits) { + paths.add(path) + } + } + return [...paths] +} + +describe('config/relay-assets line-ending pin', () => { + it('pins every tracked relay artifact source to LF', () => { + const assets = trackedManifestSources() + expect(assets.length).toBeGreaterThan(0) + + const attributes = eolAttributes(assets) + const unpinned = assets.filter((path) => attributes.get(path) !== 'lf') + + expect( + unpinned, + 'A relay asset left on the platform default gets CRLF on a Windows runner, ' + + 'which changes the .version hash and splits one release across two remote ' + + 'relay directories. Pin it in .gitattributes.' + ).toEqual([]) + }) + + // Why: the assertion above only sees files that exist today. These fix the + // pattern itself -- broad enough to cover a file added tomorrow, narrow enough + // not to claim neighbours. + it.each([ + ['config/relay-assets/example.cjs', 'lf'], + ['config/relay-assets/nested/deeper/example.cjs', 'lf'], + ['config/relay-assets/example.txt', 'lf'], + ['config/relay-assets-extra/example.cjs', 'unspecified'], + ['vendor/config/relay-assets/example.cjs', 'unspecified'] + ])('resolves %s to eol=%s', (path, expected) => { + expect(eolAttributes([path]).get(path)).toBe(expected) + }) +}) diff --git a/config/scripts/relay-frame-buffer-benchmark.mjs b/config/scripts/relay-frame-buffer-benchmark.mjs new file mode 100644 index 00000000000..24d7b565400 --- /dev/null +++ b/config/scripts/relay-frame-buffer-benchmark.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { performance } from 'node:perf_hooks' + +// Pass the pre-change source saved with git show :src/shared/relay-frame-buffer.ts. +const baselinePath = process.argv[2] +if (!baselinePath) { + throw new Error('Usage: node config/scripts/relay-frame-buffer-benchmark.mjs ') +} +async function load(source) { + return ( + await import( + `data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}` + ) + ).RelayFrameBuffer +} +const Before = await load(readFileSync(baselinePath, 'utf8')) +const After = await load( + readFileSync(new URL('../../src/shared/relay-frame-buffer.ts', import.meta.url), 'utf8') +) +function median(values) { + return values.sort((a, b) => a - b)[Math.floor(values.length / 2)] +} +for (const count of [1, 256, 16384, 65536]) { + const chunks = Array.from({ length: count }, (_, index) => Buffer.alloc(64, index % 256)) + const expected = Buffer.concat(chunks) + for (const mode of ['take', 'discard']) { + const times = [[], []] + for (let round = 0; round < 9; round += 1) { + for (const arm of round % 2 === 0 ? [0, 1] : [1, 0]) { + const FrameBuffer = arm === 0 ? Before : After + const buffer = new FrameBuffer() + for (const chunk of chunks) { + buffer.append(chunk) + } + const start = performance.now() + const output = buffer[mode](expected.length) + times[arm].push(performance.now() - start) + if (mode === 'take') { + assert.deepEqual(output, expected) + } + assert.equal(buffer.length, 0) + buffer.append(Buffer.from('tail')) + assert.equal(buffer.drain().toString(), 'tail') + } + } + const beforeMs = median(times[0]), + afterMs = median(times[1]) + console.log( + JSON.stringify({ + mode, + chunks: count, + bytes: expected.length, + beforeMs, + afterMs, + speedup: beforeMs / afterMs + }) + ) + } +} diff --git a/config/scripts/replace-cached-nsis-elevate.mjs b/config/scripts/replace-cached-nsis-elevate.mjs new file mode 100644 index 00000000000..fcd1a7323d4 --- /dev/null +++ b/config/scripts/replace-cached-nsis-elevate.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node + +// Why: electron-builder re-runs `CopyElevateHelper.copy` on every NSIS pack, so the +// release rebuild overwrites the SignPath-signed `resources/elevate.exe` with the +// unsigned copy sitting in the electron-builder toolset cache. The release workflow +// swapped the cached copy first, but searched `/nsis` — a directory no current +// app-builder-lib layout creates (real ones are `/nsis-3.0.4.1/nsis-3.0.4.1-/` +// and `/nsis@/nsis-bundle--/`), so the swap silently found +// nothing and v1.4.193/v1.4.194 shipped an unsigned UAC elevation helper. + +import { copyFileSync, readdirSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { homedir, platform as osPlatform, tmpdir } from 'node:os' +import { join, parse, resolve } from 'node:path' + +const require = createRequire(import.meta.url) + +const ELEVATE_EXE = 'elevate.exe' + +// `nsis` (the layout the old hardcoded path assumed), `nsis-3.0.4.1` (legacy bundle via +// `getBinFromUrl`), `nsis@1.2.1` (unified bundle). Not `customNsisBinary`: the +// `nsis-` key `getBinFromCustomLoc` builds is only `getBin`'s in-process promise +// key, and the extract dir is named for the custom URL's parent segment, which need not +// start with `nsis` at all. Only the app-builder-lib probe covers that layout — which is +// why the probe, not this scan, is what decides whether the swap succeeded. +const NSIS_RELEASE_DIR = /^nsis(?:[-@].*)?$/i + +// elevate.exe lives at the bundle root, one level under the release dir. The legacy +// bundle carries thousands of files under Contrib/, so an unbounded walk is both slow +// and a way to match something that is not a toolset copy. +const MAX_DEPTH = 3 + +function isFile(path) { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +/** + * Mirrors `getCacheDirectory` in app-builder-lib's `out/util/electronGet.js`, which is what + * decides where the NSIS bundle is unpacked. Kept as a local port rather than an import + * because the swap must still resolve a cache root when app-builder-lib cannot be loaded. + */ +export function resolveElectronBuilderCacheDir({ + env = process.env, + platform = osPlatform(), + home = homedir(), + temp = tmpdir() +} = {}) { + const override = env.ELECTRON_BUILDER_CACHE?.trim() + if (override && parse(override).root) { + return override + } + if (platform === 'darwin') { + return join(home, 'Library', 'Caches', 'electron-builder') + } + if (platform === 'win32') { + const localAppData = env.LOCALAPPDATA?.trim() + // https://github.com/electron-userland/electron-builder/issues/1164 + const isSystemUser = + localAppData?.toLowerCase().includes('\\windows\\system32\\') === true || + env.USERNAME?.trim().toLowerCase() === 'system' + if (!localAppData || isSystemUser) { + return join(temp, 'electron-builder-cache') + } + return join(localAppData, 'electron-builder', 'Cache') + } + const xdgCache = env.XDG_CACHE_HOME + return xdgCache && parse(xdgCache).root + ? join(xdgCache, 'electron-builder') + : join(home, '.cache', 'electron-builder') +} + +function collectElevateFiles(dir, depth, found) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isFile()) { + if (entry.name.toLowerCase() === ELEVATE_EXE) { + found.push(path) + } + } else if (entry.isDirectory() && depth > 1) { + collectElevateFiles(path, depth - 1, found) + } + } + return found +} + +/** + * Every cached `elevate.exe` under an NSIS release directory of `cacheDir`, plus the + * `ELECTRON_BUILDER_NSIS_DIR` override copy when that is set. + */ +export function findCachedElevatePaths(cacheDir, { env = process.env } = {}) { + const found = [] + const overrideDir = env.ELECTRON_BUILDER_NSIS_DIR?.trim() + if (overrideDir && isFile(join(overrideDir, ELEVATE_EXE))) { + found.push(join(overrideDir, ELEVATE_EXE)) + } + let entries + try { + entries = readdirSync(cacheDir, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + if (entry.isDirectory() && NSIS_RELEASE_DIR.test(entry.name)) { + collectElevateFiles(join(cacheDir, entry.name), MAX_DEPTH, found) + } + } + return found +} + +/** + * The exact path `CopyElevateHelper` will pack, asked of app-builder-lib itself. Returns the + * failure instead of logging it: an unavailable probe leaves the directory scan as the only + * signal, and the caller has to say that out loud rather than quietly passing. + */ +export async function resolveToolsetElevatePath(projectDir = process.cwd()) { + try { + const configPath = require.resolve(resolve(projectDir, 'config/electron-builder.config.cjs')) + const config = require(configPath) + const { getNsisElevatePath } = require('app-builder-lib/out/toolsets/windows.js') + const path = await getNsisElevatePath(config.toolsets?.nsis, config.nsis?.customNsisBinary) + return { path, error: null } + } catch (error) { + return { path: null, error: error.message } + } +} + +/** + * Replaces every cached copy rather than picking one. Which bundle the rebuild packs + * depends on the toolset version resolved at pack time, and each cached copy is an + * unsigned `elevate.exe` that a later pack could reach for; the helper is a standalone + * UAC shim, not coupled to the NSIS version around it, so overwriting all of them is safe. + * + * `toolsetReplaced` is the signal that matters. A non-empty `replaced` only says that some + * cached copy was rewritten, which a stale release directory carried in by the + * `electron-builder-win-` prefix restore can satisfy on its own. + */ +export async function replaceCachedElevateHelpers({ + signedPath, + cacheDir = resolveElectronBuilderCacheDir(), + projectDir = process.cwd(), + env = process.env, + probe = resolveToolsetElevatePath +} = {}) { + if (!isFile(signedPath)) { + throw new Error(`Signed elevate.exe not found: ${signedPath}`) + } + const targets = new Set(findCachedElevatePaths(cacheDir, { env })) + const { path: toolsetPath, error: toolsetError } = await probe(projectDir) + if (toolsetPath != null && isFile(toolsetPath)) { + targets.add(toolsetPath) + } + + const replaced = [] + for (const target of targets) { + copyFileSync(signedPath, target) + replaced.push(target) + } + return { + replaced, + cacheDir, + toolsetPath, + toolsetError, + toolsetReplaced: toolsetPath != null && replaced.includes(toolsetPath) + } +} + +/** + * The annotations and exit code a swap result earns. Split out so every branch is testable + * without a subprocess — including the one that made this defect class possible, where the + * step passes because *a* cached copy was replaced while the copy the rebuild packs was not. + */ +export function summarizeSwap({ replaced, cacheDir, toolsetPath, toolsetError, toolsetReplaced }) { + if (toolsetPath != null && !toolsetReplaced) { + return { + annotations: [ + { + level: 'error', + message: + `app-builder-lib resolves the elevate.exe the NSIS rebuild will pack to ${toolsetPath}, ` + + 'but that path could not be replaced, so the installer will ship an unsigned UAC ' + + 'elevation helper.' + } + ], + exitCode: 1 + } + } + if (replaced.length === 0) { + return { + annotations: [ + { + level: 'error', + message: + `No cached elevate.exe found under ${cacheDir}; the NSIS rebuild will pack the unsigned ` + + 'helper and ship an unsigned UAC elevation binary. The electron-builder toolset cache ' + + 'layout has changed — update config/scripts/replace-cached-nsis-elevate.mjs.' + } + ], + exitCode: 1 + } + } + if (toolsetPath == null) { + // A green step must never quietly mean "the authoritative check did not run". The scan + // alone is satisfiable by a stale release directory that the `electron-builder-win-` + // prefix restore carried across a lockfile change, while the bundle the rebuild actually + // packs sits in a directory this scan does not match. + return { + annotations: [ + { + level: 'warning', + message: + 'Could not ask app-builder-lib which elevate.exe the NSIS rebuild will pack ' + + `(${toolsetError}); replaced ${replaced.length} copies found by scanning ${cacheDir} ` + + 'alone, which a stale release directory can satisfy while the packed copy stays unsigned.' + } + ], + exitCode: 0 + } + } + return { annotations: [], exitCode: 0 } +} + +// Why an exit code and not a warning: a swap that misses the copy the rebuild packs exits +// before that rebuild restores the unsigned helper, so a silent success here is +// indistinguishable from a release that shipped a signed one — which is how this went +// unnoticed for two releases. The workflow step is `continue-on-error`, so this annotates +// loudly without making a release unbuildable. +if (import.meta.filename === process.argv[1]) { + const signedPath = process.argv[2] + if (!signedPath) { + process.stderr.write('Usage: replace-cached-nsis-elevate.mjs \n') + process.exit(2) + } + try { + const result = await replaceCachedElevateHelpers({ signedPath }) + const { annotations, exitCode } = summarizeSwap(result) + for (const { level, message } of annotations) { + process.stdout.write(`::${level}::${message}\n`) + } + if (exitCode === 0) { + for (const path of result.replaced) { + const role = path === result.toolsetPath ? ' (the copy app-builder-lib will pack)' : '' + process.stdout.write(`Replaced ${path} with the SignPath-signed copy.${role}\n`) + } + } + process.exit(exitCode) + } catch (error) { + process.stdout.write(`::error::Could not replace the cached elevate.exe: ${error.message}\n`) + process.exit(1) + } +} diff --git a/config/scripts/replace-cached-nsis-elevate.test.mjs b/config/scripts/replace-cached-nsis-elevate.test.mjs new file mode 100644 index 00000000000..a88461703c3 --- /dev/null +++ b/config/scripts/replace-cached-nsis-elevate.test.mjs @@ -0,0 +1,364 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +import { + findCachedElevatePaths, + replaceCachedElevateHelpers, + resolveElectronBuilderCacheDir, + summarizeSwap +} from './replace-cached-nsis-elevate.mjs' + +// The probe is app-builder-lib asking itself where the packed elevate.exe lives; injected +// here so no test needs the network or a warm toolset cache. +const probeFound = (path) => async () => ({ path, error: null }) +const probeUnavailable = async () => ({ path: null, error: 'app-builder-lib not loadable' }) + +const projectRoot = resolve(import.meta.dirname, '../..') +const scriptPath = join(projectRoot, 'config/scripts/replace-cached-nsis-elevate.mjs') + +let scratch + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), 'orca elevate swap ')) +}) + +afterEach(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function makeCache(...relativeFiles) { + const cacheDir = join(scratch, 'Cache') + for (const relative of relativeFiles) { + const path = join(cacheDir, ...relative.split('/')) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, 'unsigned-elevate') + } + mkdirSync(cacheDir, { recursive: true }) + return cacheDir +} + +describe('cached elevate.exe swap covers the real electron-builder layouts', () => { + // Why these exact shapes: `downloadBuilderToolset` unpacks to + // `//-/`, and `releaseName` is + // `nsis-3.0.4.1` on the legacy bundle (`getBinFromUrl`) and `nsis@` on the + // unified bundle. The release workflow searched `/nsis`, which matches none of + // them. `customNsisBinary` is deliberately absent — see the probe suite below. + it.each([ + ['legacy bundle', 'nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe'], + ['unified bundle', 'nsis@1.2.1/nsis-bundle-3.12-k4d9x/elevate.exe'], + ['bare nsis release dir', 'nsis/nsis-3.0.4.1/elevate.exe'] + ])('finds the cached helper in the %s layout', (_label, relative) => { + const cacheDir = makeCache(relative) + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([ + join(cacheDir, ...relative.split('/')) + ]) + }) + + it('leaves other toolsets and the raw download dir alone', () => { + const cacheDir = makeCache( + 'winCodeSign/winCodeSign-2.6.0-abc12/elevate.exe', + 'downloads/nsis/elevate.exe' + ) + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([]) + }) + + // `nsis-resources-3.4.1` matches the release-dir pattern and is scanned. Documented + // rather than excluded: `getLegacyNsisResourcesBin` ships plugins, never an elevate.exe, + // so the over-match costs one cheap directory read and nothing else. Narrowing the + // pattern to exclude it would be a guess about a name app-builder-lib owns. + it('scans the resources bundle too, which ships no helper to find', () => { + expect( + findCachedElevatePaths(makeCache('nsis-resources-3.4.1/plugins/x86-unicode/nsProcess.dll'), { + env: {} + }) + ).toEqual([]) + + const planted = 'nsis-resources-3.4.1/nsis-resources-3.4.1-p8w1z/elevate.exe' + const cacheDir = makeCache(planted) + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([ + join(cacheDir, ...planted.split('/')) + ]) + }) + + // The rebuild picks one bundle, and nothing outside app-builder-lib knows which. + // Replacing every cached copy is the deliberate answer to that ambiguity. + it('replaces every cached copy when several bundles are present', async () => { + const cacheDir = makeCache( + 'nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe', + 'nsis@1.2.1/nsis-bundle-3.12-k4d9x/elevate.exe' + ) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const { replaced } = await replaceCachedElevateHelpers({ + signedPath: signed, + cacheDir, + env: {}, + probe: probeUnavailable + }) + + expect(replaced).toHaveLength(2) + for (const path of replaced) { + expect(readFileSync(path, 'utf8')).toBe('signpath-signed-elevate') + } + }) + + it('covers the ELECTRON_BUILDER_NSIS_DIR override copy', () => { + const overrideDir = join(scratch, 'nsis-override') + mkdirSync(overrideDir, { recursive: true }) + writeFileSync(join(overrideDir, 'elevate.exe'), 'unsigned-elevate') + const cacheDir = makeCache() + + expect( + findCachedElevatePaths(cacheDir, { env: { ELECTRON_BUILDER_NSIS_DIR: overrideDir } }) + ).toEqual([join(overrideDir, 'elevate.exe')]) + }) + + it('resolves the cache root the same way app-builder-lib does', () => { + expect( + resolveElectronBuilderCacheDir({ + env: { LOCALAPPDATA: 'C:\\Users\\runneradmin\\AppData\\Local' }, + platform: 'win32' + }) + ).toBe(join('C:\\Users\\runneradmin\\AppData\\Local', 'electron-builder', 'Cache')) + expect(resolveElectronBuilderCacheDir({ env: {}, platform: 'darwin', home: '/Users/a' })).toBe( + join('/Users/a', 'Library', 'Caches', 'electron-builder') + ) + expect(resolveElectronBuilderCacheDir({ env: { ELECTRON_BUILDER_CACHE: '/mnt/cache' } })).toBe( + '/mnt/cache' + ) + }) + + // Proof against the layout actually on disk, not just the fixtures. Cross-checked + // against an independent unbounded walk so a search that scopes itself wrongly + // cannot pass by finding nothing — which is exactly how the inline path passed. + // Skipped only where no NSIS bundle has been downloaded into the cache yet. + it('finds every elevate.exe the real electron-builder cache holds', (ctx) => { + const cacheDir = resolveElectronBuilderCacheDir() + if (!existsSync(cacheDir)) { + // Reported as skipped, never as passed: this is the one test that checks the scan + // against a layout nobody wrote down, and a silent no-op here is the suite + // confirming itself. The Linux unit-test job has no electron-builder cache. + ctx.skip() + return + } + const walk = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return walk(path) + } + return entry.name.toLowerCase() === 'elevate.exe' ? [path] : [] + }) + const onDisk = walk(cacheDir) + if (onDisk.length === 0) { + ctx.skip() + return + } + expect(findCachedElevatePaths(cacheDir, { env: {} }).sort()).toEqual(onDisk.sort()) + }) +}) + +describe('the probe, not the scan, decides whether the swap worked', () => { + // Why the probe is load-bearing: `getBinFromCustomLoc` passes `nsis-` to `getBin` + // as its in-process promise key only — the extract dir is named for the custom URL's parent + // segment, so a customNsisBinary bundle can sit outside `nsis*` entirely. + it('covers a custom bundle the directory scan cannot match', async () => { + const relative = 'orca-nsis-mirror/nsis-custom-3.11-0zqp2/elevate.exe' + const cacheDir = makeCache(relative) + const packed = join(cacheDir, ...relative.split('/')) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + expect(findCachedElevatePaths(cacheDir, { env: {} })).toEqual([]) + + const result = await replaceCachedElevateHelpers({ + signedPath: signed, + cacheDir, + env: {}, + probe: probeFound(packed) + }) + + expect(result.toolsetReplaced).toBe(true) + expect(readFileSync(packed, 'utf8')).toBe('signpath-signed-elevate') + expect(summarizeSwap(result)).toEqual({ annotations: [], exitCode: 0 }) + }) + + // The shape that reproduced the hole: release-cut.yml restores the toolset cache with + // `restore-keys: electron-builder-win-`, so a stale release directory survives a lockfile + // change. Replacing that stale copy satisfies `replaced.length > 0` on its own while the + // bundle the rebuild packs sits in a directory the scan never matches. + it('does not call a stale directory a success when the packed bundle is unmatched', async () => { + const stale = 'nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe' + const packed = 'builder-nsis@4.0.0/nsis-bundle-4.0-k4d9x/elevate.exe' + const cacheDir = makeCache(stale, packed) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = await replaceCachedElevateHelpers({ + signedPath: signed, + cacheDir, + env: {}, + probe: probeUnavailable + }) + + // The scan rewrote only the stale copy; the one that would be packed is untouched. + expect(result.replaced).toEqual([join(cacheDir, ...stale.split('/'))]) + expect(readFileSync(join(cacheDir, ...packed.split('/')), 'utf8')).toBe('unsigned-elevate') + + // So the run must not look clean. + const { annotations, exitCode } = summarizeSwap(result) + expect(exitCode).toBe(0) + expect(annotations).toHaveLength(1) + expect(annotations[0].level).toBe('warning') + expect(annotations[0].message).toContain('Could not ask app-builder-lib') + }) + + it('fails when the probe names a copy that could not be replaced', () => { + const summary = summarizeSwap({ + replaced: ['C:/cache/nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe'], + cacheDir: 'C:/cache', + toolsetPath: 'C:/cache/nsis@2.0.0/nsis-bundle-4.0-k4d9x/elevate.exe', + toolsetError: null, + toolsetReplaced: false + }) + + expect(summary.exitCode).toBe(1) + expect(summary.annotations[0].level).toBe('error') + expect(summary.annotations[0].message).toContain('will pack') + }) + + it('fails when nothing at all was replaced', () => { + const summary = summarizeSwap({ + replaced: [], + cacheDir: 'C:/cache', + toolsetPath: null, + toolsetError: 'app-builder-lib not loadable', + toolsetReplaced: false + }) + + expect(summary.exitCode).toBe(1) + expect(summary.annotations[0].level).toBe('error') + expect(summary.annotations[0].message).toContain('No cached elevate.exe found') + }) +}) + +describe('a cached elevate.exe miss is not silent', () => { + // ELECTRON_BUILDER_NSIS_DIR short-circuits app-builder-lib's own resolution before + // any download, so the probe fails offline instead of fetching the NSIS bundle. + function runScript(cacheDir, nsisDir, signedPath) { + return spawnSync(process.execPath, [scriptPath, signedPath], { + cwd: projectRoot, + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_BUILDER_CACHE: cacheDir, + ELECTRON_BUILDER_NSIS_DIR: nsisDir + } + }) + } + + it('exits non-zero with an ::error:: annotation when no cached copy is found', () => { + const cacheDir = makeCache() + const emptyNsisDir = join(scratch, 'empty-nsis') + mkdirSync(emptyNsisDir, { recursive: true }) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = runScript(cacheDir, emptyNsisDir, signed) + + expect(result.status).toBe(1) + expect(result.stdout).toContain('::error::No cached elevate.exe found') + }) + + it('warns on the scan-only path so green never means the probe was skipped', () => { + const cacheDir = makeCache('nsis-3.0.4.1/nsis-3.0.4.1-1mx3n/elevate.exe') + const emptyNsisDir = join(scratch, 'empty-nsis') + mkdirSync(emptyNsisDir, { recursive: true }) + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = runScript(cacheDir, emptyNsisDir, signed) + + expect(result.status).toBe(0) + expect(result.stdout).not.toContain('::error::') + expect(result.stdout).toContain('::warning::Could not ask app-builder-lib') + expect( + readFileSync(join(cacheDir, 'nsis-3.0.4.1', 'nsis-3.0.4.1-1mx3n', 'elevate.exe'), 'utf8') + ).toBe('signpath-signed-elevate') + }) + + // The healthy release-job path: app-builder-lib answers, so the copy it will pack is the + // one that gets replaced and there is nothing to warn about. + it('exits clean when the probe resolves the copy the rebuild will pack', () => { + const cacheDir = makeCache() + const nsisDir = join(scratch, 'nsis-bundle') + mkdirSync(nsisDir, { recursive: true }) + writeFileSync(join(nsisDir, 'elevate.exe'), 'unsigned-elevate') + const signed = join(scratch, 'signed-elevate.exe') + writeFileSync(signed, 'signpath-signed-elevate') + + const result = runScript(cacheDir, nsisDir, signed) + + expect(result.status).toBe(0) + expect(result.stdout).not.toContain('::error::') + expect(result.stdout).not.toContain('::warning::') + expect(result.stdout).toContain('the copy app-builder-lib will pack') + expect(readFileSync(join(nsisDir, 'elevate.exe'), 'utf8')).toBe('signpath-signed-elevate') + }) +}) + +describe('release-cut.yml swaps the cached elevate.exe through the resolver', () => { + function swapStep() { + const workflow = parse( + readFileSync(join(projectRoot, '.github/workflows/release-cut.yml'), 'utf8') + ) + const step = workflow.jobs.build.steps.find( + (candidate) => candidate.name === 'Replace cached elevate.exe with the signed copy' + ) + expect(step).toBeDefined() + return step + } + + it('delegates the cache lookup to the script instead of an inline path', () => { + const step = swapStep() + expect(step.run).toContain('node config/scripts/replace-cached-nsis-elevate.mjs $signed') + // The hardcoded miss that shipped v1.4.193/v1.4.194 unsigned. + expect(step.run).not.toContain('electron-builder\\Cache\\nsis') + expect(step.run).not.toContain('-ErrorAction SilentlyContinue') + }) + + it('fails the step when the swap reports a miss', () => { + const step = swapStep() + // Matched as an executed statement: downgrading this to a Write-Host restores + // the silent fail-open that let the unsigned helper ship. + expect(step.run).toMatch(/if \(\$LASTEXITCODE -ne 0\) \{/) + expect(step.run).toMatch(/^\s*throw \$message\s*$/m) + expect(step.run).toContain('GITHUB_STEP_SUMMARY') + }) + + // Why kept: windows-signing-rehearsal.yml shares the electron-builder-win- + // cache key, so dropping this guard would let a test certificate reach a release cache. + it('still refuses to stage anything but a SignPath-signed helper', () => { + const step = swapStep() + expect(step.run).toContain("$signature.Status -ne 'Valid'") + expect(step.run).toContain("$subject -notlike '*CN=SignPath Foundation*'") + }) + + // The inner-signing chain stays fail-open: a loud red step, not an unbuildable release. + it('keeps the step unable to fail the release job', () => { + expect(swapStep()['continue-on-error']).toBe(true) + }) +}) diff --git a/config/scripts/repo-icon-source-href-benchmark.mjs b/config/scripts/repo-icon-source-href-benchmark.mjs new file mode 100644 index 00000000000..76c42d261b4 --- /dev/null +++ b/config/scripts/repo-icon-source-href-benchmark.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import { extractIconHref } from '../../src/main/repo-icon-source-href.ts' + +// Original production expressions, preserved for the before/after measurement. +const html = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const object = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i +const original = (source) => source.match(html)?.[1] ?? source.match(object)?.[1] ?? null + +function measurePair(source) { + original(source) + extractIconHref(source) + const beforeSamples = [] + const afterSamples = [] + for (let run = 0; run < 5; run++) { + const measurements = [ + [original, beforeSamples], + [extractIconHref, afterSamples] + ] + if (run % 2 === 1) { + measurements.reverse() + } + for (const [fn, samples] of measurements) { + const started = performance.now() + fn(source) + samples.push(performance.now() - started) + } + } + return { + beforeMs: beforeSamples.sort((a, b) => a - b)[2], + afterMs: afterSamples.sort((a, b) => a - b)[2] + } +} + +const results = [] +for (const size of [8192, 16384, 32768]) { + for (const shape of ['no icon', 'rel without href', 'unterminated link starts']) { + const source = + shape === 'unterminated link starts' + ? ' { + 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([]) + }) +}) diff --git a/config/scripts/source-string-blanking-benchmark.mjs b/config/scripts/source-string-blanking-benchmark.mjs new file mode 100644 index 00000000000..de677b9baa9 --- /dev/null +++ b/config/scripts/source-string-blanking-benchmark.mjs @@ -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 ') +} +const source = execFileSync('git', ['show', `${ref}:src/shared/source-scan/source-tree-scan.ts`], { + encoding: 'utf8' +}) +const { blankStringContents: before } = await import( + `data:text/javascript;base64,${Buffer.from(stripTypeScriptTypes(source)).toString('base64')}` +) +const tokens = [ + 'a', + '/', + '*', + ' ', + '\n', + '\r', + '\t', + '\u00a0', + '\u2028', + '"', + "'", + '`', + '${', + '}', + '{', + '\\', + '(', + ')', + '[', + ']', + '=', + '+', + '-', + ';' +] +let seed = 173 +for (let sample = 0; sample < 3000; sample++) { + let input = '' + for (let token = 0; token < 40; token++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + input += tokens[seed % tokens.length] + } + assert.equal(after(input), before(input), JSON.stringify(input)) + assert.equal(after(input, true), before(input, true), JSON.stringify(input)) +} +function measure(fn, input) { + const samples = [] + for (let run = 0; run < 3; run++) { + const start = performance.now() + fn(input) + samples.push(performance.now() - start) + } + return samples.sort((a, b) => a - b)[1] +} +const results = [] +for (const lines of [100, 1000, 5000, 10000]) { + const input = 'const x = value / 2;\n'.repeat(lines) + assert.equal(after(input), before(input)) + results.push({ + lines, + bytes: Buffer.byteLength(input), + beforeMs: measure(before, input), + afterMs: measure(after, input) + }) +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialCases: 3000, results }, + null, + 2 + ) +) diff --git a/config/scripts/terminal-ime-engagement-receipt.mjs b/config/scripts/terminal-ime-engagement-receipt.mjs index 9ad5255d235..8f0732908c1 100644 --- a/config/scripts/terminal-ime-engagement-receipt.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.mjs @@ -13,7 +13,8 @@ export const IME_ENGAGEMENT_RECEIPT_ENV = 'ORCA_E2E_IME_ENGAGEMENT_RECEIPT' /** The tests that must each leave a receipt. Pinned so deleting one cannot quietly shrink the lane. */ export const EXPECTED_NATIVE_IME_TESTS = [ 'forwards the issue exact-byte sequence without loss or duplication', - 'forwards the issue sentence stress sequence without leaked ASCII' + 'forwards the issue sentence stress sequence without leaked ASCII', + 'a digit typed right after a Hangul syllable reaches the pty' ] function parseReceipts(text) { diff --git a/config/scripts/terminal-ime-engagement-receipt.test.mjs b/config/scripts/terminal-ime-engagement-receipt.test.mjs index 04161339a0f..613abc2ffae 100644 --- a/config/scripts/terminal-ime-engagement-receipt.test.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.test.mjs @@ -4,7 +4,7 @@ import { verifyImeEngagementReceipts } from './terminal-ime-engagement-receipt.mjs' -const [firstTest, secondTest] = EXPECTED_NATIVE_IME_TESTS +const [firstTest, secondTest, thirdTest] = EXPECTED_NATIVE_IME_TESTS function receipt(test, overrides = {}) { return JSON.stringify({ @@ -18,9 +18,11 @@ function receipt(test, overrides = {}) { describe('verifyImeEngagementReceipts', () => { it('accepts a run where every expected test observed real composition', () => { - expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual( - [] - ) + expect( + verifyImeEngagementReceipts( + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + ) + ).toEqual([]) }) // The failure this whole mechanism exists for: Playwright reports a skipped test as a pass, so @@ -35,13 +37,20 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a partial run where only one test reached the engine', () => { expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n`)).toEqual([ - `no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed` + `no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed`, + `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` + ]) + }) + + it('requires the digit receipt even when both original native tests passed', () => { + expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual([ + `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` ]) }) it('rejects a run that typed keys but never opened a composition', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n` + `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no compositionstart — the IME never engaged` @@ -50,7 +59,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a composition that produced no Hangul, which a latin passthrough would satisfy', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n` + `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no Hangul composition data — the engine produced no syllables` @@ -59,7 +68,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a renamed test rather than counting it toward coverage', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt('some new scenario')}\n` + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt('some new scenario')}\n` ) expect(problems).toEqual([ 'unexpected engagement receipt for "some new scenario" — update EXPECTED_NATIVE_IME_TESTS' @@ -68,7 +77,7 @@ describe('verifyImeEngagementReceipts', () => { it('reports a truncated receipt rather than parsing around it', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n` + `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` ) expect(problems).toEqual(['malformed receipt line: {"test":"trunc']) }) diff --git a/config/scripts/verify-dev-channel-packaging.test.mjs b/config/scripts/verify-dev-channel-packaging.test.mjs index 63e1c7d5b0c..8e5a00f48e1 100644 --- a/config/scripts/verify-dev-channel-packaging.test.mjs +++ b/config/scripts/verify-dev-channel-packaging.test.mjs @@ -53,6 +53,19 @@ describe('electron-builder dev-channel identity', () => { expect(config.win.verifyUpdateCodeSignature).toBe(false) }) + // Why on every channel: the hook is the only handle electron-builder gives on + // the NSIS uninstaller, and it signs nothing — it relays the file to and from + // the CI SignPath request. Carrying it must not drag a publisherName onto a + // dev build, which is the failure the split above exists to prevent. + it('carries the uninstaller sign hook without changing publisherName semantics', () => { + for (const env of [{}, WIN_ADHOC_ENV]) { + const config = loadConfigWithEnv(env) + expect(typeof config.win.signtoolOptions.sign).toBe('function') + } + expect(loadConfigWithEnv({}).win.signtoolOptions.publisherName).toBe('SignPath Foundation') + expect(loadConfigWithEnv(WIN_ADHOC_ENV).win.signtoolOptions.publisherName).toBeUndefined() + }) + it.each([ ['hourly', { ORCA_WIN_HOURLY: '1' }, 'orca-hourly'], ['daily', { ORCA_WIN_DAILY: '1' }, 'orca-daily'], diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index c815407d6d0..20d91e55497 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -9,7 +9,8 @@ * hop escapes the store and configure fails with "node_addon_api.gyp not * found" (run 32999886072). */ -import { copyFileSync, mkdirSync, realpathSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' @@ -22,6 +23,16 @@ export const WINDOWS_PROCESS_TREE_PACKAGE_DIR = join( 'windows-process-tree' ) +export const WINDOWS_PROCESS_TREE_PATCH_PATH = join( + ROOT, + 'config', + 'patches', + '@vscode__windows-process-tree@0.8.0.patch' +) + +/** Only the patched reader defines this; the upstream one walks the PEB. */ +const COMMAND_LINE_PATCH_MARKER = 'kProcessCommandLineInformation' + export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [ 'napi.h', 'napi-inl.h', @@ -39,6 +50,119 @@ export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE } } +/** The binary the addon actually loads. */ +export function windowsProcessTreeAddonPath(packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { + return join(packageDir, 'build', 'Release', 'windows_process_tree.node') +} + +/** The import whose absence tells the patched binary from the published prebuilt. */ +const FLAGGED_IMPORT = 'ReadProcessMemory' + +/** + * Does this compiled addon still carry the flagged primitive? + * + * The patched reader never calls `ReadProcessMemory`, so the symbol is absent + * from its import table; the upstream build imports it. That makes this a + * property of the binary rather than of the source next to it, which matters + * because the published tarball ships a *loadable* prebuilt built from + * unpatched source: it is node-addon-api, so it satisfies a bare `require()` + * under both Node and Electron, and a skipped rebuild would use it. + * + * Tri-state, not a predicate: a binary that is not there has not been cleared, + * and a boolean makes "absent" indistinguishable from "verified clean" at every + * call site. Takes the binary path so the relay's staged addon -- which sits + * beside the bundle, with no package around it -- gets the same check. + * + * @param {string} addonPath + * @returns {'clean' | 'unpatched' | 'missing'} + */ +export function inspectWindowsProcessTreeAddon(addonPath) { + if (!existsSync(addonPath)) { + return 'missing' + } + return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean' +} + +/** + * Refuse to compile or load the upstream command-line reader. + * + * Unpatched, it opens every process with `PROCESS_VM_READ` and walks the PEB to + * recover the command line -- the primitive MDE scores as credential dumping, + * and the reason this package is patched at all. pnpm has been seen + * materializing this CRLF package with its patch missing, so repair the source + * from the patch file, and drop any binary that predates the repair. + */ +export function ensureWindowsProcessTreeCommandLinePatch( + packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR +) { + const source = join(packageDir, 'src', 'process_commandline.cc') + if (!existsSync(source)) { + throw new Error( + `${source} is missing, so the command-line patch cannot be verified. Run pnpm install.` + ) + } + let repaired = false + + if (!readFileSync(source, 'utf8').includes(COMMAND_LINE_PATCH_MARKER)) { + try { + execFileSync( + 'git', + [ + // Why force the line-ending mode: the patch is stored LF (a contract + // test forbids CR bytes in it), but upstream ships this source CRLF, + // so its pre-image lines and the file's differ by a CR. Under + // `core.autocrlf=false` -- Git's own built-in default, and what + // "checkout as-is" selects in the Git for Windows installer -- git + // compares them literally, the hunk does not match, and the repair + // throws. `input` normalizes line endings for that comparison and + // nothing else, so a hunk whose real content drifted is still + // rejected. Measured: without it, apply exits 1 at autocrlf=false and + // 0 at true/input; with it, 0 for CRLF and LF sources under all three. + '-c', + 'core.autocrlf=input', + 'apply', + '--include=src/process_commandline.cc', + WINDOWS_PROCESS_TREE_PATCH_PATH + ], + { + cwd: realpathSync(packageDir), + stdio: 'pipe', + // Why blind git to the repo: run inside a work tree, `git apply` + // prefixes patch paths with the cwd-relative prefix, silently skips + // everything that does not match -- and still exits 0. The package + // dir is always under the project root, so without this the repair + // reports success and changes nothing. + env: { ...process.env, GIT_DIR: join(packageDir, '.orca-no-such-git-dir') } + } + ) + } catch (error) { + throw new Error( + 'src/process_commandline.cc still reads the PEB, and repairing it from ' + + `${WINDOWS_PROCESS_TREE_PATCH_PATH} failed: ${error?.message ?? error}. Run pnpm install.` + ) + } + if (!readFileSync(source, 'utf8').includes(COMMAND_LINE_PATCH_MARKER)) { + throw new Error( + 'src/process_commandline.cc still reads the PEB after repair, so the patch did not ' + + 'apply. Run pnpm install.' + ) + } + repaired = true + } + + // A binary from before the repair -- or the tarball's own prebuilt -- would + // otherwise survive a skipped rebuild and load the flagged reader anyway. + // Deleting it can fail EPERM against a loaded (memory-mapped) addon, which + // `force: true` does not cover -- it only swallows ENOENT. That throw is the + // caller's to classify as a Windows file lock, so it must not be swallowed. + if (inspectWindowsProcessTreeAddon(windowsProcessTreeAddonPath(packageDir)) === 'unpatched') { + rmSync(windowsProcessTreeAddonPath(packageDir), { force: true }) + repaired = true + } + + return repaired +} + // Patched binding.gyp includes deps/node-addon-api; the tarball does not ship those headers. export function stageWindowsProcessTreeNodeAddonApiHeaders( packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index f4820e9430a..f2939b71179 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -10,8 +10,9 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, @@ -59,3 +60,40 @@ describe('windows-process-tree node-gyp rebuild', () => { } }) }) + +describe('inspecting a compiled windows-process-tree addon', () => { + let dir + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-addon-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('reports a binary that still imports ReadProcessMemory as unpatched', () => { + const addonPath = join(dir, 'windows_process_tree.node') + writeFileSync(addonPath, Buffer.from('MZ\0\0KERNEL32.dll\0ReadProcessMemory\0', 'binary')) + expect(inspectWindowsProcessTreeAddon(addonPath)).toBe('unpatched') + }) + + it('reports a binary without the import as clean', () => { + const addonPath = join(dir, 'windows_process_tree.node') + writeFileSync(addonPath, Buffer.from('MZ\0\0ntdll.dll\0NtQueryInformationProcess\0', 'binary')) + expect(inspectWindowsProcessTreeAddon(addonPath)).toBe('clean') + }) + + // The whole point of the tri-state: absence is not evidence of safety, and a + // boolean made "there is no binary" indistinguishable from "checked, clean". + it('reports an absent binary as missing rather than clean', () => { + expect(inspectWindowsProcessTreeAddon(join(dir, 'windows_process_tree.node'))).toBe('missing') + }) + + it('inspects whatever path it is handed, including a relay-staged addon', () => { + // The relay loads `./windows-process-tree.node` beside its bundle, which is + // nowhere near a node_modules package directory. + const staged = join(dir, 'windows-process-tree.node') + writeFileSync(staged, Buffer.from('MZ\0\0ReadProcessMemory\0', 'binary')) + expect(inspectWindowsProcessTreeAddon(staged)).toBe('unpatched') + }) +}) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs index 37edc2196d4..c321db8cfd2 100644 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -212,6 +213,7 @@ describe('Windows signing workflow contract', () => { 'Notify Slack that inner-binary signing is waiting for approval', 'Download signed inner binaries from SignPath', 'Restore signed inner binaries into unpacked app', + 'Restore signed uninstaller for the installer rebuild', 'Replace cached elevate.exe with the signed copy', 'Rebuild NSIS installer from signed unpacked app' ] @@ -222,3 +224,235 @@ describe('Windows signing workflow contract', () => { } }) }) + +// Why these exist: the NSIS uninstaller is generated inside electron-builder's +// uninstaller pass and deleted immediately after being embedded, so the only way +// CI can sign it is the export/import relay through win.signtoolOptions.sign. +// Every link is asserted here the way Orca.exe and conpty_console_list.node are. +describe('Windows NSIS uninstaller signing', () => { + const releaseSteps = () => readWorkflow('.github/workflows/release-cut.yml').jobs.build.steps + const stepNamed = (steps, name) => steps.find((step) => step.name === name) + + const EXPORT_ENV = 'ORCA_WIN_UNINSTALLER_EXPORT_PATH' + const SIGNED_ENV = 'ORCA_WIN_UNINSTALLER_SIGNED_PATH' + + it('exports the uninstaller from the first Windows build', () => { + const build = stepNamed(releaseSteps(), 'Build Windows release artifacts') + + expect(build.env[EXPORT_ENV]).toContain('uninstaller-signing') + expect(build.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') + }) + + // Why this is a test and not a comment: `files` in the electron-builder config + // is all-negation, so app-builder packs whatever is left in the checkout root. + // These steps retry, and a retried attempt would pack an unsigned .exe into + // app.asar — the very defect this chain removes. Every relay path must live + // outside the checkout. + it('keeps every relay path out of the packed checkout', () => { + const relayEnvValues = [ + ...releaseSteps(), + ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps + ].flatMap((step) => [step.env?.[EXPORT_ENV], step.env?.[SIGNED_ENV]].filter(Boolean)) + + expect(relayEnvValues.length).toBe(4) + for (const value of relayEnvValues) { + expect(value).toContain('runner.temp') + expect(value).not.toContain('github.workspace') + } + + const relayScripts = [ + ...releaseSteps(), + ...readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse.steps + ] + .map((step) => step.run ?? '') + .filter((run) => run.includes('uninstaller-signing')) + + expect(relayScripts.length).toBeGreaterThan(0) + for (const run of relayScripts) { + // Why count occurrences rather than assert `toContain` once: a step + // carrying two relay paths could root the first in RUNNER_TEMP and leave + // the second bare-relative — which resolves against the checkout, and is + // exactly the shape of the defect this test exists to catch. + const mentions = run.match(/uninstaller-signing/g) ?? [] + const rooted = run.match(/Join-Path \$env:RUNNER_TEMP 'uninstaller-signing/g) ?? [] + + expect(rooted.length, run).toBe(mentions.length) + expect(run).not.toContain('$env:GITHUB_WORKSPACE') + } + }) + + it('stages the uninstaller into the same request as the inner binaries', () => { + const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') + + expect(stage.run).toContain('uninstaller-signing\\unsigned\\orca-uninstaller.exe') + expect(stage.run).toContain('uninstaller\\orca-uninstaller.exe') + // No third SignPath request: exactly two submissions, as budgeted for the + // 1h + 4h approval waits inside the 360-minute job cap. + const submissions = releaseSteps().filter( + (step) => step.uses === 'signpath/github-action-submit-signing-request@v2' + ) + expect(submissions).toHaveLength(2) + }) + + // A staged-but-unreturned uninstaller must not fail the inner chain, or a + // SignPath artifact-configuration gap would cost the inner-binary signatures. + it('keeps the uninstaller out of the inner-binary copy-back list', () => { + const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') + const restoreInner = stepNamed( + releaseSteps(), + 'Restore signed inner binaries into unpacked app' + ) + + expect(stage.run).not.toMatch(/\$list\.Add\(['"]uninstaller/) + expect(restoreInner.run).not.toContain('orca-uninstaller.exe') + }) + + // This step's outcome gates the upload of every inner binary, so a filesystem + // error while staging the uninstaller must not escape — otherwise one + // uninstaller-specific failure costs every inner-binary signature, which is + // strictly worse than the behaviour before this chain existed. + it('cannot let an uninstaller staging failure cost the inner-binary signatures', () => { + const stage = stepNamed(releaseSteps(), 'Stage unsigned inner PE files for signing') + const uninstallerBlock = stage.run.slice(stage.run.indexOf('$exportedUninstaller')) + + expect(stage.run).toMatch(/try \{[\s\S]*\$exportedUninstaller[\s\S]*\} catch \{/) + expect(uninstallerBlock).toContain('::warning::Could not stage the NSIS uninstaller') + expect(uninstallerBlock).not.toContain('throw') + // Explicit, so the catch does not silently depend on GitHub's + // $ErrorActionPreference='Stop' default for `shell: pwsh`. + expect(uninstallerBlock).toContain('New-Item -ItemType Directory -Force -Path (Split-Path') + expect(uninstallerBlock).toMatch(/New-Item[^\r\n]*-ErrorAction Stop/) + expect(uninstallerBlock).toMatch(/Copy-Item[^\r\n]*-ErrorAction Stop/) + // The upload it gates still keys off this step, so the catch is load-bearing. + expect(stepNamed(releaseSteps(), 'Upload unsigned inner binaries for SignPath').if).toContain( + "steps.stage-inner.outcome == 'success'" + ) + }) + + it('re-injects the signed uninstaller into the rebuilt installer', () => { + const steps = releaseSteps() + const restore = stepNamed(steps, 'Restore signed uninstaller for the installer rebuild') + const rebuild = stepNamed(steps, 'Rebuild NSIS installer from signed unpacked app') + const names = steps.map((step) => step.name) + + expect(restore.if).toContain('github.run_attempt == 1') + expect(restore.if).toContain("steps.restore-signed-inner.outcome == 'success'") + expect(restore.run).toContain('orca-uninstaller.exe') + expect(names.indexOf(restore.name)).toBeLessThan(names.indexOf(rebuild.name)) + expect(rebuild.env[SIGNED_ENV]).toContain('uninstaller-signing') + // The rebuild must not depend on the uninstaller leg: a missing signed + // uninstaller ships today's installer, it does not skip the rebuild. + expect(rebuild.if).not.toContain('restore-signed-uninstaller') + }) + + // NSIS hides the uninstaller in a compressed data section the bundled 7za + // cannot read, so the gate proves it from the sign hook's digest receipt + // instead of extracting it — and only when the relay actually ran. + it('reports the embedded uninstaller in the inner-binary evidence gate', () => { + const gate = stepNamed(releaseSteps(), 'Verify Windows inner binary signatures') + + expect(gate.env.UNINSTALLER_SIGNING_COMPLETED).toBe( + "${{ steps.restore-signed-uninstaller.outcome == 'success' }}" + ) + expect(gate.run).toContain('.embedded-sha256') + expect(gate.run).toContain("$env:UNINSTALLER_SIGNING_COMPLETED -eq 'true'") + expect(gate.run).toContain('not signed by SignPath Foundation: Uninstall Orca.exe') + // The uninstaller must not join the 7z payload loop, which cannot see it. + expect(gate.run).not.toContain("$targets += 'Uninstall Orca.exe'") + }) + + it('rehearses the uninstaller leg end to end', () => { + const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse + .steps + const names = steps.map((step) => step.name) + const pack = stepNamed(steps, 'Package Windows app and export the NSIS uninstaller') + const rebuild = stepNamed(steps, 'Build NSIS installer from signed unpacked app') + const verify = stepNamed(steps, 'Verify signatures end to end') + + // --dir never produces an uninstaller, so the rehearsal has to build the + // installer the way release-cut's first Windows pass does. + expect(pack.run).toContain('--win --publish never') + expect(pack.run).not.toContain('--dir') + expect(pack.env[EXPORT_ENV]).toContain('orca-uninstaller.exe') + expect(names).toContain('Restore signed uninstaller for the installer rebuild') + expect(rebuild.env[SIGNED_ENV]).toContain('orca-uninstaller.exe') + expect(verify.run).toContain('.embedded-sha256') + // The receipt only proves the import leg ran. The rehearsal is where the + // shipped uninstaller itself gets checked — the release job cannot install + // onto the runner it publishes from. + expect(verify.run).toContain('shipped: Uninstall Orca.exe') + expect(verify.run).toContain('-tnsis') + expect(verify.run).toContain("-ArgumentList '/S'") + }) + + // This workflow is the merge gate, so it must not be able to fail on its own + // artefact: 7-Zip's NSIS handler is unreliable enough that its output has to + // be corroborated before a signature verdict is drawn from it. + it('never lets an unreliable extract fail the rehearsal', () => { + const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse + .steps + const verify = stepNamed(steps, 'Verify signatures end to end') + + // The 7-Zip route is only trusted when it reproduces the relayed bytes; + // otherwise it falls through to the install route rather than failing. + expect(verify.run).toContain( + 'Write-Host "7-Zip\'s NSIS output did not match the relayed digest; falling back to a silent install."' + ) + expect(verify.run).toMatch(/\$installedUninstaller = \$null\r?\n\s*\}/) + + // The comparison that is not tautological: a file NSIS wrote out, against + // the digest the sign hook recorded. + expect(verify.run).toContain('$shippedDigest -ne $expectedDigest') + expect(verify.run).toContain('the uninstaller the installer ships is not the relayed one') + + // An installer that prompts must not hang to the 360-minute job cap, and + // the app it launches must not outlive the step holding install-dir handles. + expect(verify.run).toContain('-PassThru') + expect(verify.run).toContain('$installerProcess.WaitForExit(300000)') + expect(verify.run).toContain('the silent install did not exit within 5 minutes') + expect(verify.run).toMatch(/for \(\$attempt = 0; \$attempt -lt 20; \$attempt\+\+\)/) + expect(verify.run).toContain("Get-Process -Name 'orca-terminal-daemon'") + }) + + // resources\elevate.exe is downgraded to advisory because app-builder-lib's + // CopyElevateHelper clobbers it on every nsis pack — a pre-existing defect + // that predates the uninstaller relay and is being tracked separately. The + // escape hatch it needed is the kind that quietly grows until the gate + // asserts nothing, so pin it to exactly that one file. + it('confines the advisory escape hatch to elevate.exe', () => { + const steps = readWorkflow('.github/workflows/windows-signing-rehearsal.yml').jobs.rehearse + .steps + const verify = stepNamed(steps, 'Verify signatures end to end') + const advisoryCalls = verify.run + .split('\n') + .filter((line) => line.includes('-Advisory') && line.includes('Test-Signature')) + + expect(advisoryCalls).toHaveLength(1) + expect(advisoryCalls[0]).toContain('installed: $relative') + expect(verify.run).toContain("if ($relative -eq 'resources\\elevate.exe')") + + // Both uninstaller verdicts stay fatal — the whole point of the gate. + for (const call of ['relayed: orca-uninstaller.exe', 'shipped: Uninstall Orca.exe']) { + const line = verify.run + .split('\n') + .find((it) => it.includes(`Test-Signature`) && it.includes(call)) + expect(line, call).toBeDefined() + expect(line, call).not.toContain('-Advisory') + } + + // An advisory must still reach the evidence artifact, or downgrading it + // becomes indistinguishable from deleting the check. + expect(verify.run).toContain('ADVISORY (known pre-existing') + expect(verify.run).toContain('$script:advisories.Add($problem)') + }) + + it('wires the electron-builder sign hook that the relay depends on', () => { + const require = createRequire(import.meta.url) + const configPath = resolve(projectDir, 'config/electron-builder.config.cjs') + delete require.cache[require.resolve(configPath)] + const config = require(configPath) + + expect(typeof config.win.signtoolOptions.sign).toBe('function') + delete require.cache[require.resolve(configPath)] + }) +}) diff --git a/config/scripts/windows-uninstaller-signing.cjs b/config/scripts/windows-uninstaller-signing.cjs new file mode 100644 index 00000000000..c3243b4581a --- /dev/null +++ b/config/scripts/windows-uninstaller-signing.cjs @@ -0,0 +1,111 @@ +// Why this exists: the NSIS uninstaller is the one Orca binary SignPath never +// saw. app-builder-lib builds it in a separate makensis pass, hands it to the +// packager's sign hook, embeds it in the installer, then deletes it +// (NsisTarget.computeScriptAndSignUninstaller → packager.signIf(uninstallerPath), +// then `unlink(defines.UNINSTALLER_OUT_FILE)`). That hook is the only moment the +// file exists on disk, so it is the only place a post-hoc signer can reach it. +// +// Orca does not sign during electron-builder — SignPath signs afterwards, behind +// a human approval — so instead of signing, this hook relays: build 1 exports the +// unsigned uninstaller so CI can put it in the existing inner-binaries SignPath +// request, and the rebuild-from-signed-tree pass swaps the signed bytes back in +// before makensis embeds them. +// +// Trap for whoever adds a real certificate to the Windows build: a custom sign +// hook *replaces* signtool rather than running alongside it — windowsSignToolManager +// does `const executor = customSign || (config => this.doSign(config))`. Inert +// today (no CSC_LINK/WIN_CSC_LINK anywhere in the Windows workflows), but setting +// one would silently sign nothing until this hook learns to delegate. +// +// Trap for whoever adds a second NSIS target or arch: app-builder-lib names the +// intermediate uninstaller per target *and* arch, while the relay is a single +// pair of env vars. Two targets would race — last write wins on export, every +// installer would embed the same uninstaller, and the receipt could not tell. +// Release is x64-only `--win` with `win.target` unset (so `["nsis"]`) today. +const { createHash } = require('node:crypto') +const { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs') +const { basename, dirname } = require('node:path') + +// app-builder-lib names the intermediate uninstaller `__uninstaller.exe`. +const UNINSTALLER_BASENAME_SUFFIX = '__uninstaller.exe' + +// Why a receipt: NSIS embeds the uninstaller in its own compressed data section, +// not in the app 7z payload the evidence gate extracts, so the shipped installer +// cannot be inspected for it with the bundled 7za. The receipt records the digest +// of the exact bytes handed to makensis, which the gate compares against the +// SignPath-returned file — proving what was embedded without extracting it. +const EMBEDDED_RECEIPT_SUFFIX = '.embedded-sha256' + +const isNsisUninstallerArtifact = (filePath) => + typeof filePath === 'string' && basename(filePath).endsWith(UNINSTALLER_BASENAME_SUFFIX) + +/** + * Pure relay. Returns a short verdict string for logging and tests. + * Never throws: a relay failure must ship today's installer, not break the build. + */ +function relayNsisUninstaller({ + filePath, + exportPath, + signedPath, + fs = { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } +}) { + if (!isNsisUninstallerArtifact(filePath)) { + return 'not-uninstaller' + } + try { + // Import wins over export: the rebuild pass must embed the signed bytes even + // though it also regenerates an unsigned uninstaller of its own. + if (signedPath) { + if (!fs.existsSync(signedPath)) { + return 'signed-missing' + } + fs.copyFileSync(signedPath, filePath) + const digest = createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') + fs.writeFileSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`, digest) + return 'imported' + } + if (exportPath) { + fs.mkdirSync(dirname(exportPath), { recursive: true }) + fs.copyFileSync(filePath, exportPath) + return 'exported' + } + return 'idle' + } catch (error) { + return `failed: ${error.message}` + } +} + +const VERDICT_MESSAGES = { + imported: (paths) => `embedded the SignPath-signed uninstaller from ${paths.signedPath}`, + exported: (paths) => `exported the unsigned uninstaller to ${paths.exportPath}`, + 'signed-missing': (paths) => + `no signed uninstaller at ${paths.signedPath}; embedding the unsigned one (fail-open)` +} + +/** + * electron-builder `win.signtoolOptions.sign` hook. Called for every Windows + * executable, twice per file (once per signing hash), so it must be cheap for + * non-uninstaller paths and idempotent for the uninstaller. + */ +function signWindowsUninstallerViaSignPath(configuration) { + const paths = { + filePath: configuration?.path, + exportPath: process.env.ORCA_WIN_UNINSTALLER_EXPORT_PATH || undefined, + signedPath: process.env.ORCA_WIN_UNINSTALLER_SIGNED_PATH || undefined + } + const verdict = relayNsisUninstaller(paths) + const message = VERDICT_MESSAGES[verdict] + if (message) { + console.log(`[win-uninstaller-signing] ${message(paths)}`) + } else if (verdict.startsWith('failed')) { + console.warn(`[win-uninstaller-signing] ${verdict}; embedding the unsigned uninstaller.`) + } +} + +module.exports = { + EMBEDDED_RECEIPT_SUFFIX, + UNINSTALLER_BASENAME_SUFFIX, + isNsisUninstallerArtifact, + relayNsisUninstaller, + signWindowsUninstallerViaSignPath +} diff --git a/config/scripts/windows-uninstaller-signing.test.mjs b/config/scripts/windows-uninstaller-signing.test.mjs new file mode 100644 index 00000000000..57ebfbdf786 --- /dev/null +++ b/config/scripts/windows-uninstaller-signing.test.mjs @@ -0,0 +1,235 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + EMBEDDED_RECEIPT_SUFFIX, + isNsisUninstallerArtifact, + relayNsisUninstaller, + signWindowsUninstallerViaSignPath +} = require('./windows-uninstaller-signing.cjs') + +const makeDir = () => mkdtempSync(join(tmpdir(), 'orca-uninstaller-signing-')) + +describe('isNsisUninstallerArtifact', () => { + // The name app-builder-lib's NsisTarget.computeScriptAndSignUninstaller gives + // the intermediate uninstaller; the hook keys off nothing else. + it('matches only electron-builder intermediate uninstallers', () => { + expect(isNsisUninstallerArtifact('C:\\dist\\orca-windows-setup.__uninstaller.exe')).toBe(true) + expect(isNsisUninstallerArtifact('/dist/orca-windows-setup.__uninstaller.exe')).toBe(true) + expect(isNsisUninstallerArtifact('C:\\dist\\win-unpacked\\Orca.exe')).toBe(false) + expect(isNsisUninstallerArtifact('C:\\dist\\orca-windows-setup.exe')).toBe(false) + expect(isNsisUninstallerArtifact(undefined)).toBe(false) + }) +}) + +describe('relayNsisUninstaller', () => { + const writeUninstaller = (dir, contents) => { + const filePath = join(dir, 'orca-windows-setup.__uninstaller.exe') + writeFileSync(filePath, contents) + return filePath + } + + it('ignores every file that is not the uninstaller', () => { + const dir = makeDir() + const filePath = join(dir, 'Orca.exe') + writeFileSync(filePath, 'app') + expect(relayNsisUninstaller({ filePath, exportPath: join(dir, 'out', 'x.exe') })).toBe( + 'not-uninstaller' + ) + }) + + it('exports the unsigned uninstaller, creating the destination directory', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + const exportPath = join(dir, 'uninstaller-signing', 'unsigned', 'orca-uninstaller.exe') + + expect(relayNsisUninstaller({ filePath, exportPath })).toBe('exported') + expect(readFileSync(exportPath, 'utf8')).toBe('unsigned-uninstaller') + }) + + it('overwrites the freshly built uninstaller with the signed bytes', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'rebuild-unsigned') + const signedPath = join(dir, 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'signed')) + writeFileSync(signedPath, 'signpath-signed') + + expect(relayNsisUninstaller({ filePath, signedPath })).toBe('imported') + expect(readFileSync(filePath, 'utf8')).toBe('signpath-signed') + }) + + // The receipt is the evidence gate's only handle on the embedded uninstaller: + // NSIS hides it in a compressed section the bundled 7za cannot read. + it('records the digest of the bytes it handed makensis', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'rebuild-unsigned') + const signedPath = join(dir, 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'signed')) + writeFileSync(signedPath, 'signpath-signed') + + relayNsisUninstaller({ filePath, signedPath }) + + const expected = createHash('sha256').update('signpath-signed').digest('hex') + expect(readFileSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`, 'utf8')).toBe(expected) + }) + + it('leaves no receipt when the signed uninstaller never came back', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + const signedPath = join(dir, 'absent', 'orca-uninstaller.exe') + + relayNsisUninstaller({ filePath, signedPath }) + + expect(existsSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`)).toBe(false) + }) + + // Import wins so the rebuild pass embeds the signed bytes even though it also + // regenerates an unsigned uninstaller of its own. + it('prefers importing over exporting when both are configured', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'rebuild-unsigned') + const signedPath = join(dir, 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'signed')) + writeFileSync(signedPath, 'signpath-signed') + + expect( + relayNsisUninstaller({ filePath, signedPath, exportPath: join(dir, 'out', 'x.exe') }) + ).toBe('imported') + expect(readFileSync(filePath, 'utf8')).toBe('signpath-signed') + }) + + // Fail-open: a missing or unwritable relay must leave the build with today's + // unsigned uninstaller, never throw. + it('leaves the unsigned uninstaller in place when no signed copy came back', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + + expect( + relayNsisUninstaller({ filePath, signedPath: join(dir, 'absent', 'orca-uninstaller.exe') }) + ).toBe('signed-missing') + expect(readFileSync(filePath, 'utf8')).toBe('unsigned-uninstaller') + }) + + it('swallows filesystem errors instead of failing the build', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + const fs = { + existsSync: () => true, + mkdirSync: () => {}, + copyFileSync: () => { + throw new Error('EACCES') + } + } + + expect(relayNsisUninstaller({ filePath, exportPath: join(dir, 'x.exe'), fs })).toBe( + 'failed: EACCES' + ) + }) + + it('does nothing when neither relay path is configured (local builds)', () => { + const dir = makeDir() + const filePath = writeUninstaller(dir, 'unsigned-uninstaller') + + expect(relayNsisUninstaller({ filePath })).toBe('idle') + expect(readFileSync(filePath, 'utf8')).toBe('unsigned-uninstaller') + }) +}) + +// Why a suite of its own: this is the function electron-builder actually calls, +// and it runs inside `Build Windows release artifacts`, which has no +// continue-on-error. If it throws, the release job dies before a single +// SignPath request is made. Nothing else in the chain guards that. +describe('signWindowsUninstallerViaSignPath', () => { + const RELAY_VARS = ['ORCA_WIN_UNINSTALLER_EXPORT_PATH', 'ORCA_WIN_UNINSTALLER_SIGNED_PATH'] + + const withEnv = (env, run) => { + const saved = Object.fromEntries(RELAY_VARS.map((key) => [key, process.env[key]])) + const apply = (values) => { + for (const key of RELAY_VARS) { + if (values[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = values[key] + } + } + } + apply({ ...Object.fromEntries(RELAY_VARS.map((key) => [key, undefined])), ...env }) + try { + return run() + } finally { + apply(saved) + } + } + + const writeBuiltUninstaller = (dir) => { + const filePath = join(dir, 'orca-windows-setup.__uninstaller.exe') + writeFileSync(filePath, 'built-by-makensis') + return filePath + } + + it.each([ + ['a missing configuration', undefined], + ['a configuration with no path', {}], + ['a non-uninstaller path', { path: 'C:\\dist\\win-unpacked\\Orca.exe' }] + ])('never throws on %s', (_label, configuration) => { + withEnv({ ORCA_WIN_UNINSTALLER_EXPORT_PATH: join(makeDir(), 'out', 'x.exe') }, () => { + expect(() => signWindowsUninstallerViaSignPath(configuration)).not.toThrow() + }) + }) + + // electron-builder calls the hook once per signing hash (sha1 then sha256), + // so both legs have to survive running twice over the same file. + it('is idempotent across the sha1 and sha256 invocations on both legs', () => { + const dir = makeDir() + const filePath = writeBuiltUninstaller(dir) + const exportPath = join(dir, 'relay', 'unsigned', 'orca-uninstaller.exe') + + withEnv({ ORCA_WIN_UNINSTALLER_EXPORT_PATH: exportPath }, () => { + signWindowsUninstallerViaSignPath({ path: filePath }) + signWindowsUninstallerViaSignPath({ path: filePath }) + }) + expect(readFileSync(exportPath, 'utf8')).toBe('built-by-makensis') + + const signedPath = join(dir, 'relay', 'signed', 'orca-uninstaller.exe') + mkdirSync(join(dir, 'relay', 'signed'), { recursive: true }) + writeFileSync(signedPath, 'signpath-signed') + + withEnv({ ORCA_WIN_UNINSTALLER_SIGNED_PATH: signedPath }, () => { + signWindowsUninstallerViaSignPath({ path: filePath }) + signWindowsUninstallerViaSignPath({ path: filePath }) + }) + expect(readFileSync(filePath, 'utf8')).toBe('signpath-signed') + expect(readFileSync(`${signedPath}${EMBEDDED_RECEIPT_SUFFIX}`, 'utf8')).toBe( + createHash('sha256').update('signpath-signed').digest('hex') + ) + }) + + // An unwritable destination is the realistic filesystem failure, and it must + // cost the uninstaller signature rather than the release job. + it('never throws when the export destination cannot be created', () => { + const dir = makeDir() + const filePath = writeBuiltUninstaller(dir) + const blocker = join(dir, 'blocker') + writeFileSync(blocker, 'not a directory') + + withEnv({ ORCA_WIN_UNINSTALLER_EXPORT_PATH: join(blocker, 'sub', 'x.exe') }, () => { + expect(() => signWindowsUninstallerViaSignPath({ path: filePath })).not.toThrow() + }) + expect(readFileSync(filePath, 'utf8')).toBe('built-by-makensis') + }) + + it('does nothing when neither relay variable is set (local Windows builds)', () => { + const dir = makeDir() + const filePath = writeBuiltUninstaller(dir) + + withEnv({}, () => { + expect(() => signWindowsUninstallerViaSignPath({ path: filePath })).not.toThrow() + }) + expect(readFileSync(filePath, 'utf8')).toBe('built-by-makensis') + }) +}) diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs new file mode 100644 index 00000000000..31366f5e489 --- /dev/null +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -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"/) + } + ) +}) diff --git a/config/scripts/workflow-ref-reachability.test.mjs b/config/scripts/workflow-ref-reachability.test.mjs new file mode 100644 index 00000000000..d71c3094c56 --- /dev/null +++ b/config/scripts/workflow-ref-reachability.test.mjs @@ -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) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 1b9600188f2..2423647577b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts new file mode 100644 index 00000000000..7682b7b9698 --- /dev/null +++ b/config/vitest.performance.config.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 + } +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index fe660c42295..33ad276aa2d 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 40m + + downloads: 41m @@ -15,7 +15,7 @@ downloads downloads - 40m - 40m + 41m + 41m diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md new file mode 100644 index 00000000000..6d688598097 --- /dev/null +++ b/docs/reference/ci-runner-efficiency.md @@ -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. diff --git a/docs/reference/windows-cmd-shim-resolution.md b/docs/reference/windows-cmd-shim-resolution.md new file mode 100644 index 00000000000..c17380e800d --- /dev/null +++ b/docs/reference/windows-cmd-shim-resolution.md @@ -0,0 +1,77 @@ +# Resolving Windows `.cmd` shims past cmd.exe + +Node refuses to spawn a `.cmd`/`.bat` target without a shell (the +CVE-2024-27980 mitigation), so `resolveSpawn` has to make `cmd.exe` the program +and hand it `/d /v:off /s /c ""`. For an agent CLI that +means a long `cmd.exe /c` line whose caret-escaped payload is natural-language +prompt text — which Microsoft Defender for Endpoint's command-line model scores +as obfuscation. `codex.cmd` appeared in the spawn cluster of an MDE incident +against Orca for exactly this reason. + +`src/shared/child-process/windows-cmd-shim-resolution.ts` sidesteps it. npm's +`cmd-shim` and pnpm's `@zkochan/cmd-shim` generate files whose entire body is +"find a Node interpreter and run this script". Reading one lets `resolveSpawn` +spawn `node.exe

literal

\n\`\`\`` + expect(normalizeMobileMarkdownPreviewHtml(input)).toBe(input) + }) + + it('handles adjacent markers and repeated maximum suffixes', () => { + const literal = `${marker}${marker}__0${suffix}${marker}__1${suffix}${marker}_2${suffix}` + expect(normalizeMobileMarkdownPreviewHtml(`

${literal} and \`

\`

`)).toBe( + `${literal} and \`
\`` + ) + }) + + it('preserves authored markers across generated suffix orders and HTML islands', () => { + let seed = 173 + for (let sample = 0; sample < 500; sample++) { + const literals: string[] = [] + for (let index = 0; index < 8; index++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + literals.push(`${marker}${'_'.repeat(seed % 32)}${index}${suffix}`) + } + const text = literals.join(' ') + ' and `Array`' + expect(normalizeMobileMarkdownPreviewHtml(`

${text}

`)).toBe(text) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatToolRun.tsx b/mobile/src/session/MobileNativeChatToolRun.tsx index db3ddbea063..ccc732dab88 100644 --- a/mobile/src/session/MobileNativeChatToolRun.tsx +++ b/mobile/src/session/MobileNativeChatToolRun.tsx @@ -14,9 +14,9 @@ import { describeActiveToolCall, formatActiveToolLabel, formatToolCallCount, - isCommandToolName, selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' +import { isShellActivityToolCall } from '../../../src/shared/native-chat-tool-icon' import type { NativeChatBlock } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-message-styles' @@ -195,7 +195,10 @@ export function ToolRun({ } callCount ||= pairs.length const summary = summarizeToolRun(blocks) - const ActiveToolIcon = activeCall && isCommandToolName(activeCall.name) ? SquareTerminal : Wrench + // The call's input, not its word: Codex names a classified shell row + // `read`/`search`/`list` and keeps the command it ran, while Claude's `Read` + // shares that word and ran none. + const ActiveToolIcon = activeCall && isShellActivityToolCall(activeCall) ? SquareTerminal : Wrench return ( diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts index 548d0614837..8de107c9fb0 100644 --- a/mobile/src/session/mobile-native-chat-autocomplete.ts +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -81,7 +81,7 @@ export function rankSuggestions(candidates: readonly string[], query: string, li const substring: string[] = [] for (const candidate of candidates) { const lower = candidate.toLowerCase() - const base = lower.split('/').pop() ?? lower + const base = lower.slice(lower.lastIndexOf('/') + 1) if (lower.startsWith(q) || base.startsWith(q)) { prefix.push(candidate) } else if (lower.includes(q)) { diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index c6e5f434326..e1ea2f8ec15 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -79,7 +79,7 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - 'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f' + '0c08a53c2cd1e182e1d7edfb7b98bd9e4a313e47c7b93f5a509a89ec3292bc1f' const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = @@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(546) + expect(strings).toHaveLength(547) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(124) diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 5086efe1ba1..374a9eefd94 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from 'react' -import type { Keyboard, TextInput } from 'react-native' +import { Platform, type Keyboard, type TextInput } from 'react-native' import { useFocusEffect } from 'expo-router' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -142,6 +142,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM lifecycleIdentity: client, lifecycleKey: JSON.stringify([hostId, worktreeId, connState]), liveInputEnabled, + reopenFocusedInputWhenKeyboardHidden: Platform.OS === 'android', timerRef: liveInputFocusTimerRef }) useFocusEffect( diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index daca212d4a8..a9ec903f637 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -196,18 +196,40 @@ describe('terminal live input', () => { const input = createFocusTarget(() => true) const refocus = vi.fn() - focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + focusTerminalLiveInputTarget(input, { + keyboardHeight: 0, + refocus, + reopenFocusedInputWhenKeyboardHidden: true + }) expect(input.blur).toHaveBeenCalledTimes(1) expect(input.focus).not.toHaveBeenCalled() expect(refocus).toHaveBeenCalledTimes(1) }) + it('keeps an iPad hardware-keyboard responder focused when the software keyboard is absent', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { + keyboardHeight: 0, + refocus, + reopenFocusedInputWhenKeyboardHidden: false + }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) it('focuses the capture input directly when the keyboard is open', () => { const input = createFocusTarget(() => true) const refocus = vi.fn() - focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus }) + focusTerminalLiveInputTarget(input, { + keyboardHeight: 240, + refocus, + reopenFocusedInputWhenKeyboardHidden: true + }) expect(input.blur).not.toHaveBeenCalled() expect(input.focus).toHaveBeenCalledTimes(1) @@ -218,7 +240,11 @@ describe('terminal live input', () => { const input = createFocusTarget(() => false) const refocus = vi.fn() - focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + focusTerminalLiveInputTarget(input, { + keyboardHeight: 0, + refocus, + reopenFocusedInputWhenKeyboardHidden: true + }) expect(input.blur).not.toHaveBeenCalled() expect(input.focus).toHaveBeenCalledTimes(1) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index 321226c0c7d..42b4d5cb2cf 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -75,6 +75,7 @@ export type TerminalLiveInputFocusTarget = { type FocusTerminalLiveInputTargetOptions = { readonly keyboardHeight: number readonly refocus: () => void + readonly reopenFocusedInputWhenKeyboardHidden: boolean } export type TerminalLiveInputDefaultResult = { @@ -230,13 +231,17 @@ export function scheduleTerminalLiveInputFocus( export function focusTerminalLiveInputTarget( input: TerminalLiveInputFocusTarget | null, - { keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions + { + keyboardHeight, + refocus, + reopenFocusedInputWhenKeyboardHidden + }: FocusTerminalLiveInputTargetOptions ): void { if (!input) { return } - if (keyboardHeight <= 0 && input.isFocused?.()) { + if (reopenFocusedInputWhenKeyboardHidden && keyboardHeight <= 0 && input.isFocused?.()) { // Why: Android can keep a hidden TextInput focused after the IME is dismissed; // focus() is then a no-op, so force a new focus session to reopen the keyboard. input.blur() diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts index 92ce4e2c455..cb906b164c7 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -15,6 +15,7 @@ type HarnessProps = { readonly lifecycleIdentity: object | null readonly lifecycleKey: string readonly liveInputEnabled: boolean + readonly reopenFocusedInputWhenKeyboardHidden: boolean readonly timerRef: TerminalLiveInputFocusTimerRef } @@ -92,6 +93,7 @@ function connectedProps( lifecycleIdentity: null, lifecycleKey: 'host-a:worktree-a:connected', liveInputEnabled: true, + reopenFocusedInputWhenKeyboardHidden: true, timerRef } } @@ -128,6 +130,22 @@ describe('terminal live input focus hook', () => { expect(input.focus).toHaveBeenCalledTimes(2) harness.unmount() }) + it('preserves the focused iPad responder when a hardware keyboard keeps keyboard height at zero', () => { + vi.useFakeTimers() + const input = createFocusTarget(true) + const inputRef = { current: input } + const harness = createHarness({ + ...connectedProps(inputRef), + reopenFocusedInputWhenKeyboardHidden: false + }) + + harness.handlers().handleTerminalTap('terminal-a') + vi.runAllTimers() + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) it('cancels focus when navigation reuses the mounted route', () => { vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-focus.ts b/mobile/src/terminal/use-terminal-live-input-focus.ts index b0ab456b721..36fa0f5272e 100644 --- a/mobile/src/terminal/use-terminal-live-input-focus.ts +++ b/mobile/src/terminal/use-terminal-live-input-focus.ts @@ -11,6 +11,7 @@ type TerminalLiveInputFocusContext = { readonly canSend: boolean readonly keyboardHeight: number readonly liveInputEnabled: boolean + readonly reopenFocusedInputWhenKeyboardHidden: boolean } type UseTerminalLiveInputFocusOptions = @@ -35,17 +36,24 @@ export function useTerminalLiveInputFocus): TerminalLiveInputFocusHandlers { const contextRef = useRef({ canSend, keyboardHeight, - liveInputEnabled + liveInputEnabled, + reopenFocusedInputWhenKeyboardHidden }) useLayoutEffect(() => { - contextRef.current = { canSend, keyboardHeight, liveInputEnabled } - }, [canSend, keyboardHeight, liveInputEnabled]) + contextRef.current = { + canSend, + keyboardHeight, + liveInputEnabled, + reopenFocusedInputWhenKeyboardHidden + } + }, [canSend, keyboardHeight, liveInputEnabled, reopenFocusedInputWhenKeyboardHidden]) const resetLiveInputFocus = useCallback(() => { clearTerminalLiveInputFocusTimer(timerRef) @@ -62,6 +70,7 @@ export function useTerminalLiveInputFocus scheduleTerminalLiveInputFocus(timerRef, focusLiveInput) }) }, [inputRef, timerRef]) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts index 69fe8a2ab8a..4049b4b073b 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts @@ -71,4 +71,114 @@ describe('mobile direct endpoint probe', () => { expect(clients.get(host.endpoint)?.close).toHaveBeenCalledOnce() expect(result?.client.close).not.toHaveBeenCalled() }) + + it('fails a whole dead LAN in seconds instead of holding the 12s bound', async () => { + // Incident 2026-09-04: foregrounding on a dead LAN produced an instant 1006 and + // the direct client's 500/1000/2000ms redials, while the probe sat on the + // 'connecting' phase and held the supervisor mutex for the whole 12s bound. + const clients: FakeClient[] = [] + const openDirect = vi.fn(() => { + const client = new FakeClient('connecting') + clients.push(client) + setTimeout(() => client.publishState('reconnecting'), 20) + return client + }) + + const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + await vi.advanceTimersByTimeAsync(20) + await vi.advanceTimersByTimeAsync(2_000) + await expect(probing).resolves.toBeNull() + + expect(clients).toHaveLength(2) + for (const client of clients) { + expect(client.close).toHaveBeenCalledOnce() + } + // No 12s timer is left behind to fire into a settled probe. + expect(vi.getTimerCount()).toBe(0) + }) + + it('rides out one access-point flap that the first redial recovers', async () => { + // 'reconnecting' is published on any socket close, so a single RST on the first + // dial must not book a direct failure and its 60s cooldown. + const openDirect = vi.fn((endpoint: string) => { + const client = new FakeClient('connecting') + if (endpoint.includes('100.64.0.2')) { + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('connected'), 600) + } + return client + }) + + const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + await vi.advanceTimersByTimeAsync(600) + const result = await probing + + expect(result?.path).toBe('tailscale') + expect(result?.client.close).not.toHaveBeenCalled() + }) + + it('extends the grace once when the redial reaches a handshake', async () => { + // The redial fires at 500ms, but 'connected' waits on the Noise handshake and a + // capability RPC, so real work needs more than one grace window. + const openDirect = vi.fn((endpoint: string) => { + const client = new FakeClient('connecting') + if (endpoint.includes('100.64.0.2')) { + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('handshaking'), 1_500) + // Past the first grace window: only the re-arm keeps this probe alive. + setTimeout(() => client.publishState('connected'), 3_000) + } + return client + }) + + const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + await vi.advanceTimersByTimeAsync(3_000) + + expect((await probing)?.path).toBe('tailscale') + }) + + it('fails a handshake that stalls, one grace after it started', async () => { + const openDirect = vi.fn(() => { + const client = new FakeClient('connecting') + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('handshaking'), 1_500) + // A restarted handshake must not buy a second extension. + setTimeout(() => client.publishState('handshaking'), 2_500) + return client + }) + + const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + await vi.advanceTimersByTimeAsync(3_499) + let settled = false + void probing.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(probing).resolves.toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('gives up at the grace window when the redial never lands', async () => { + const openDirect = vi.fn(() => { + const client = new FakeClient('connecting') + setTimeout(() => client.publishState('reconnecting'), 20) + return client + }) + + const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + await vi.advanceTimersByTimeAsync(2_019) + let settled = false + void probing.then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(probing).resolves.toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) }) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.ts b/mobile/src/transport/mobile-direct-endpoint-probe.ts index 114a4f29130..038f73b93b8 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.ts @@ -25,17 +25,52 @@ export function directPathForEndpoint( return 'lan' } -function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise { +// Why: 'reconnecting' is published on any socket close, so it cannot tell a dead +// LAN (instant 1006, then doomed redials) from one access-point flap that the +// first redial recovers. One redial fits here; a dead LAN still fails in ~2s +// instead of holding the supervisor's operation mutex for the full outer bound. +const RECONNECT_GRACE_MS = 2_000 + +function waitForAuthenticatedSession( + session: RpcClient, + timeoutMs: number, + signal?: AbortSignal +): Promise { + if (signal?.aborted) { + return Promise.reject(new Error('probe cancelled')) + } if (session.getState() === 'connected') { return Promise.resolve() } return new Promise((resolve, reject) => { let timer: ReturnType | null = null + let graceTimer: ReturnType | null = null + let graceExtended = false + const armGrace = (): ReturnType => + setTimeout(() => { + finish() + reject(new Error('probe session reconnecting')) + }, RECONNECT_GRACE_MS) const unsubscribe = session.onStateChange((state) => { if (state === 'connected') { finish() resolve() - } else if (state === 'disconnected' || state === 'auth-failed') { + return + } + if (state === 'reconnecting' && !graceTimer) { + graceTimer = armGrace() + return + } + // Why: the redial fires at 500ms but 'connected' waits on the Noise handshake + // and a capability RPC. 'handshaking' is proof the peer answered, so extend + // once; a dead handshake still fails at ~4s, far inside the outer bound. + if (state === 'handshaking' && graceTimer && !graceExtended) { + graceExtended = true + clearTimeout(graceTimer) + graceTimer = armGrace() + return + } + if (state === 'disconnected' || state === 'auth-failed' || state === 'reconnecting') { finish() reject(new Error(`probe session ${state}`)) } @@ -44,10 +79,19 @@ function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Pro finish() reject(new Error('probe session authentication timed out')) }, timeoutMs) + const onAbort = (): void => { + finish() + reject(new Error('probe cancelled')) + } + signal?.addEventListener('abort', onAbort, { once: true }) function finish(): void { + signal?.removeEventListener('abort', onAbort) if (timer) { clearTimeout(timer) } + if (graceTimer) { + clearTimeout(graceTimer) + } unsubscribe() } }) @@ -56,8 +100,12 @@ function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Pro export async function openAuthenticatedDirectEndpoint( host: HostProfile, openDirect: (endpoint: string) => RpcClient, - timeoutMs: number + timeoutMs: number, + signal?: AbortSignal ): Promise<{ client: RpcClient; path: Exclude } | null> { + if (signal?.aborted) { + return null + } const endpoints = directEndpointUrls(host) return await new Promise((resolve) => { const clients = new Set() @@ -79,8 +127,13 @@ export async function openAuthenticatedDirectEndpoint( continue } clients.add(client) - void waitForAuthenticatedSession(client, timeoutMs).then( + void waitForAuthenticatedSession(client, timeoutMs, signal).then( () => { + if (signal?.aborted) { + client.close() + rejectCandidate() + return + } if (settled) { client.close() return diff --git a/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts b/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts new file mode 100644 index 00000000000..535790ed480 --- /dev/null +++ b/mobile/src/transport/mobile-direct-probe-stop-budget.test.ts @@ -0,0 +1,175 @@ +import { expect, it, vi } from 'vitest' +import { + dependencies, + FakeLogicalClient, + FakeSession, + host +} from './mobile-endpoint-supervisor-test-fakes' +import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' +import { createStableLogicalRpcClient } from './stable-logical-rpc-client' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +it('closes in-flight candidates and clears their timeout when the owner stops', async () => { + vi.useFakeTimers() + try { + const candidate = new FakeSession('connecting') + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies({ openDirect: vi.fn(() => candidate) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + await vi.advanceTimersByTimeAsync(15_000) + expect(deps.openDirect).toHaveBeenCalledOnce() + supervisor.stop() + await vi.advanceTimersByTimeAsync(0) + expect(candidate.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(12_000) + expect(vi.getTimerCount()).toBe(0) + expect(candidate.close).toHaveBeenCalledOnce() + expect(logical.migrateTo).not.toHaveBeenCalled() + expect(deps.openDirect).toHaveBeenCalledOnce() + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } +}) + +it('closes an authenticated candidate when stop races its completion', async () => { + vi.useFakeTimers() + try { + const candidate = new FakeSession('connecting') + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies({ openDirect: vi.fn(() => candidate) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + await vi.advanceTimersByTimeAsync(15_000) + candidate.publishState('connected') + supervisor.stop() + await vi.advanceTimersByTimeAsync(0) + expect(candidate.close).toHaveBeenCalledOnce() + expect(logical.migrateTo).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } +}) + +it('preserves an in-flight probe across a transient background pause', async () => { + vi.useFakeTimers() + try { + const candidate = new FakeSession('connecting') + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies({ openDirect: vi.fn(() => candidate) }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + await vi.advanceTimersByTimeAsync(15_000) + supervisor.setForeground(false) + await vi.advanceTimersByTimeAsync(0) + expect(candidate.close).not.toHaveBeenCalled() + supervisor.stop() + await vi.advanceTimersByTimeAsync(0) + expect(candidate.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } +}) + +it('releases every candidate when multiple endpoint probes are pending', async () => { + vi.useFakeTimers() + try { + const candidates: FakeSession[] = [] + const logical = new FakeLogicalClient('connected', 'relay') + const deps = dependencies({ + openDirect: vi.fn(() => { + const candidate = new FakeSession('connecting') + candidates.push(candidate) + return candidate + }) + }) + const supervisor = new MobileEndpointSupervisor( + logical, + { + ...host, + endpoints: [{ id: 'alternate', kind: 'tailscale', url: 'ws://100.64.0.2:6768' }] + }, + deps + ) + await supervisor.start() + await vi.advanceTimersByTimeAsync(15_000) + expect(candidates).toHaveLength(2) + supervisor.stop() + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(0) + for (const candidate of candidates) { + expect(candidate.close).toHaveBeenCalledOnce() + candidate.publishState('connected') + } + await vi.advanceTimersByTimeAsync(60_000) + expect(logical.migrateTo).not.toHaveBeenCalled() + expect(deps.openDirect).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } +}) + +it.each([false, true])( + 'fences migration finishing after stop (already swapped: %s)', + async (alreadySwapped) => { + vi.useFakeTimers() + try { + const recordedMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration') + const relay = new FakeSession('connected') + const logical = createStableLogicalRpcClient(relay, 'relay') + const candidates: FakeSession[] = [] + const deps = dependencies({ + openDirect: vi.fn(() => { + const candidate = new FakeSession('connected') + candidates.push(candidate) + return candidate + }) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + const migrate = logical.migrateTo.bind(logical) + let release!: () => void + const pending = new Promise((resolve) => { + release = resolve + }) + const migration = vi.spyOn(logical, 'migrateTo').mockImplementation(async (...args) => { + if (alreadySwapped) { + await migrate(...args) + } + await pending + if (!alreadySwapped) { + await migrate(...args) + } + }) + await supervisor.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(migration).toHaveBeenCalledOnce() + const requestsBeforeStop = relay.sendRequest.mock.calls.length + const candidateRequestsBeforeStop = candidates[3].sendRequest.mock.calls.length + const migrationsBeforeStop = recordedMigration.mock.calls.length + supervisor.stop() + release() + await vi.advanceTimersByTimeAsync(0) + expect(logical.getActivePath()).toBe(alreadySwapped ? 'lan' : 'relay') + expect(logical.getGeneration()).toBe(alreadySwapped ? 2 : 1) + expect(relay.sendRequest).toHaveBeenCalledTimes(requestsBeforeStop) + expect(candidates[3].sendRequest).toHaveBeenCalledTimes(candidateRequestsBeforeStop) + expect(recordedMigration).toHaveBeenCalledTimes(migrationsBeforeStop) + expect(candidates[3].close).toHaveBeenCalledTimes(alreadySwapped ? 0 : 1) + expect(vi.getTimerCount()).toBe(0) + logical.close() + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } + } +) diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts index dfb0572aa38..3ae31edd07f 100644 --- a/mobile/src/transport/mobile-direct-return-probe.ts +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -11,6 +11,9 @@ const DIRECT_PROBE_INTERVAL_MS = 15_000 export class DirectReturnProbe { private timer: ReturnType | null = null + private stopped = false + private activeProbe: AbortController | null = null + constructor( private readonly deps: { now: () => number @@ -24,14 +27,18 @@ export class DirectReturnProbe { canSchedule: () => boolean canAttempt: () => boolean beginOperation: () => void - migrate: (client: RpcClient, path: MobileConnectionPath) => Promise + migrate: ( + client: RpcClient, + path: MobileConnectionPath, + shouldAbort: () => boolean + ) => Promise onDirectMigrated: () => Promise afterProbe: () => void } ) {} schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void { - if (!this.hooks.canSchedule() || this.timer) { + if (this.stopped || !this.hooks.canSchedule() || this.timer) { return } this.timer = this.deps.setTimer(() => { @@ -47,19 +54,34 @@ export class DirectReturnProbe { } } + stop(): void { + this.stopped = true + this.clear() + this.activeProbe?.abort() + } + private async probe(): Promise { + if (this.stopped) { + return + } if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) { this.schedule() return } + const controller = new AbortController() + this.activeProbe = controller this.hooks.beginOperation() let successful: Awaited> = null try { successful = await openAuthenticatedDirectEndpoint( this.hooks.host(), this.deps.openDirect, - 12_000 + 12_000, + controller.signal ) + if (this.stopped) { + return + } if (!successful) { this.hooks.hysteresis.recordDirectFailure(this.deps.now()) return @@ -68,11 +90,24 @@ export class DirectReturnProbe { successful.client.close() return } - await this.hooks.migrate(successful.client, successful.path) + const candidate = successful + // Migration owns the candidate, including closing it if cutover is canceled. successful = null + try { + await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped) + } catch (error) { + if (this.stopped) { + return + } + throw error + } + if (this.stopped) { + return + } this.hooks.hysteresis.recordMigration(this.deps.now()) await this.hooks.onDirectMigrated() } finally { + this.activeProbe = null successful?.client.close() // Why: a relay drop or backoff timer can arrive while the probe owns the // operation mutex; afterProbe releases it and replays deferred recovery. diff --git a/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts new file mode 100644 index 00000000000..3ee52fc7ddf --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-direct-probe.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { + dependencies, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host +} from './mobile-endpoint-supervisor-test-fakes' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +describe('mobile endpoint supervisor direct probe', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-13T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not block relay recovery behind a direct probe stuck in its redial loop', async () => { + const logical = new FakeLogicalClient('connected', 'relay') + const direct = new FakeSession('connecting') + const openRelay = vi.fn(() => new FakeRelaySession('connected')) + const deps = dependencies({ openDirect: vi.fn(() => direct), openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + await supervisor.start() + + // Foreground return: the probe dials direct at once, the dead LAN answers with + // an instant 1006, and the direct client enters its 500/1000/2000ms backoff. + supervisor.setForeground(false) + supervisor.setForeground(true) + await vi.advanceTimersByTimeAsync(0) + expect(deps.openDirect).toHaveBeenCalledOnce() + direct.publishState('reconnecting') + logical.publishState('disconnected') + + // Relay recovery must not wait out the probe's 12s bound; the probe gives up + // one grace window after the redial fails to land. + await vi.advanceTimersByTimeAsync(2_000) + expect(openRelay).toHaveBeenCalledOnce() + expect(direct.close).toHaveBeenCalled() + expect(logical.getState()).toBe('connected') + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 6fca6c0cc17..9ba12f35112 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -120,7 +120,7 @@ export class MobileEndpointSupervisor { canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay', canAttempt: () => this.isActive() && !this.operationInFlight, beginOperation: () => (this.operationInFlight = true), - migrate: (client, path) => this.logical.migrateTo(client, path), + migrate: (client, path, abort) => this.logical.migrateTo(client, path, undefined, abort), onDirectMigrated: async () => { this.leaseRotation.clear() this.relayRotationPending = false @@ -195,6 +195,7 @@ export class MobileEndpointSupervisor { stop(): void { this.stopped = true + this.directProbe.stop() this.unsubscribeState?.() this.unsubscribeState = null this.backgroundGrace.stop() diff --git a/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts b/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts new file mode 100644 index 00000000000..59426fc42da --- /dev/null +++ b/mobile/src/transport/mobile-relay-browser-cancel-budget.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeBrowserScreencastController } from '../../../src/main/runtime/runtime-browser-screencast-controller' +import type { RuntimeBrowserCommands } from '../../../src/main/runtime/orca-runtime-browser' +import type { BrowserScreencastResult } from '../../../src/shared/runtime-types' +import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' +import type { RpcResponse } from './types' + +describe('relay browser cancellation resource budget', () => { + it.each([false, true])('stops host frames when cancellation precedes ready=%s', async (early) => { + const subscriptions = new Map void | Promise>() + const done = Promise.withResolvers() + const ready = Promise.withResolvers() + let sequence = 0 + let stopped = false + let frameSends = 0 + let frameBytes = 0 + let sendBinary: (bytes: Uint8Array) => boolean | void = () => false + let hostRun: Promise | undefined + const methods: string[] = [] + const cleanup = (id: string): void => { + const release = subscriptions.get(id) + subscriptions.delete(id) + void release?.() + } + const host = new RuntimeBrowserScreencastController({ + getCommands: () => + ({ + browserScreencast: async (_params, stream) => { + sendBinary = stream.sendBinary + return { + subscriptionId: 'server-stream', + ready: { type: 'ready', subscriptionId: 'server-stream', browserPageId: 'page' }, + session: { + done: done.promise, + stop: () => { + stopped = true + done.resolve() + } + }, + flushPendingFrame: () => {} + } + } + }) as RuntimeBrowserCommands, + registerSubscriptionCleanup: (id, release) => subscriptions.set(id, release), + cleanupSubscription: cleanup, + getDriver: () => ({ kind: 'idle' }), + setDriver: () => {}, + notifyRemoteViewersChanged: () => {} + }) + const streams = new MobileRelayRpcStreams({ + nextId: () => `request-${++sequence}`, + waitForConnected: async () => {}, + sendFrame: (request) => { + methods.push(request.method) + if (request.method === 'browser.screencast' && (request.params as { page?: string }).page) { + hostRun = host.start(request.params as Parameters[0], { + connectionId: 'relay-connection', + sendBinary: (bytes) => { + frameSends++ + frameBytes += bytes.byteLength + return true + }, + emit: (result: BrowserScreencastResult) => { + if (result.type === 'ready') { + ready.resolve({ + id: request.id, + ok: true, + streaming: true, + result, + _meta: { runtimeId: 'host' } + }) + } + } + }) + } else if (request.method === 'browser.screencast.unsubscribe') { + cleanup((request.params as { subscriptionId: string }).subscriptionId) + } + return true + } + }) + const cancel = streams.subscribe('browser.screencast', { page: 'page' }, () => {}) + try { + const response = await ready.promise + if (early) { + cancel() + } + streams.handleResponse(response) + if (!early) { + cancel() + } + for (let frame = 0; frame < 100; frame++) { + if (!stopped) { + sendBinary(new Uint8Array(65_536)) + } + } + expect({ stopped, subscriptions: subscriptions.size, frameSends, frameBytes }).toEqual({ + stopped: true, + subscriptions: 0, + frameSends: 0, + frameBytes: 0 + }) + expect(methods).toEqual(['browser.screencast', 'browser.screencast.unsubscribe']) + } finally { + cleanup('server-stream') + await hostRun + streams.clear() + } + }) +}) diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index 5887dffc73d..4bf617faf50 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -136,6 +136,36 @@ describe('mobile relay RPC session', () => { }) afterEach(() => vi.useRealTimers()) + it('releases stream listeners on failure even when close follows it', async () => { + const { session } = await authenticateSession() + const listener = vi.fn() + session.subscribe('runtime.clientEvents.subscribe', {}, listener) + await Promise.resolve() + const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { id: string } + fakes.linkOptions!.onText( + JSON.stringify({ + id: request.id, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId: 'server-events' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + expect(listener).toHaveBeenCalledTimes(1) + fakes.linkOptions!.onError(new Error('relay lost')) + session.close() + fakes.linkOptions!.onText( + JSON.stringify({ + id: request.id, + ok: true, + streaming: true, + result: { type: 'event' }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + expect(listener).toHaveBeenCalledTimes(1) + }) + it('requires exact resume observations and confirms by request ID before becoming connected', async () => { const { session, confirmationRequest, capabilityRequest } = await authenticateSession() diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 203a0329192..67b50ea591e 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -294,6 +294,7 @@ export function connectMobileRelayRpcSession(args: { closed = true failure = error livenessWatchdog.stop(livenessIdentity) + streams.clear() link.close() pending.rejectAll(error) publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected') diff --git a/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts b/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts new file mode 100644 index 00000000000..0a7a25dfbd7 --- /dev/null +++ b/mobile/src/transport/mobile-relay-rpc-stream-cancellation.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' +import type { RpcResponse } from './types' + +function createStreams(waitForConnected = async () => {}) { + let sequence = 0 + const sendFrame = vi.fn((_request: { id: string; method: string; params?: unknown }) => true) + const streams = new MobileRelayRpcStreams({ + nextId: () => `request-${++sequence}`, + sendFrame, + waitForConnected + }) + return { streams, sendFrame } +} + +function response(id: string, result: unknown): RpcResponse { + return { id, ok: true, streaming: true, result, _meta: { runtimeId: 'test' } } +} + +const serverSubscriptions = [ + ['browser.screencast', 'browser.screencast.unsubscribe'], + ['runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe'] +] as const + +describe('mobile relay subscription cancellation', () => { + it.each(serverSubscriptions)('cleans up ready %s exactly once', async (method, unsubscribe) => { + const { streams, sendFrame } = createStreams() + const listener = vi.fn() + const cancel = streams.subscribe(method, {}, listener) + await Promise.resolve() + streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' })) + cancel() + cancel() + expect(sendFrame.mock.calls).toEqual([ + [{ id: 'request-1', method, params: {} }], + [{ id: 'request-2', method: unsubscribe, params: { subscriptionId: 'server-1' } }] + ]) + expect(streams.handleResponse(response('request-1', { type: 'end' }))).toBe(false) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it.each(serverSubscriptions)( + 'cleans up late-ready %s without calling disposed listeners', + async (method, unsubscribe) => { + const { streams, sendFrame } = createStreams() + const listener = vi.fn() + const cancel = streams.subscribe(method, {}, listener) + await Promise.resolve() + cancel() + cancel() + expect(sendFrame).toHaveBeenCalledTimes(1) + expect(streams.handleResponse(response('request-1', { type: 'starting' }))).toBe(true) + streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' })) + expect(sendFrame).toHaveBeenLastCalledWith({ + id: 'request-2', + method: unsubscribe, + params: { subscriptionId: 'server-1' } + }) + expect( + streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' })) + ).toBe(false) + expect(listener).not.toHaveBeenCalled() + } + ) + + it.each(['error', 'end', 'disconnect', 'completed'])( + 'forgets cancelled cleanup routes on %s', + async (ending) => { + const { streams, sendFrame } = createStreams() + const cancel = streams.subscribe('browser.screencast', {}, vi.fn()) + await Promise.resolve() + cancel() + if (ending === 'disconnect') { + streams.clear() + } else if (ending === 'completed') { + streams.handleResponse({ + id: 'request-1', + ok: true, + result: null, + _meta: { runtimeId: 'test' } + }) + } else if (ending === 'error') { + streams.handleResponse({ + id: 'request-1', + ok: false, + error: { code: 'unsupported', message: 'failed' }, + _meta: { runtimeId: 'test' } + }) + } else { + streams.handleResponse(response('request-1', { type: 'end', subscriptionId: 'server-1' })) + } + expect( + streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' })) + ).toBe(false) + expect(sendFrame).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + [ + 'terminal.subscribe', + { terminal: 'term', client: { id: 'phone' } }, + 'terminal.unsubscribe', + { subscriptionId: 'term:phone', client: { id: 'phone' } } + ], + [ + 'session.tabs.subscribe', + { worktree: 'id:workspace' }, + 'session.tabs.unsubscribe', + { worktree: 'id:workspace', subscriptionId: 'request-1' } + ], + [ + 'nativeChat.subscribe', + { subscriptionId: 'chat' }, + 'nativeChat.unsubscribe', + { subscriptionId: 'chat' } + ] + ])( + 'cancels %s using its request cleanup identity', + async (method, params, unsubscribe, unsubscribeParams) => { + const { streams, sendFrame } = createStreams() + const cancel = streams.subscribe(method as string, params, vi.fn()) + await Promise.resolve() + if (method === 'session.tabs.subscribe') { + streams.handleResponse(response('request-1', { type: 'snapshot' })) + } + cancel() + expect(sendFrame).toHaveBeenLastCalledWith({ + id: 'request-2', + method: unsubscribe, + params: unsubscribeParams + }) + } + ) + + it.each([ + 'terminal.subscribe', + 'browser.screencast', + 'runtime.clientEvents.subscribe', + 'session.tabs.subscribe', + 'nativeChat.subscribe' + ])('does not unsubscribe an unsent %s', async (method) => { + const wait = Promise.withResolvers() + const { streams, sendFrame } = createStreams(() => wait.promise) + const cancel = streams.subscribe( + method, + { terminal: 'term', worktree: 'id:workspace', subscriptionId: 'chat' }, + vi.fn() + ) + cancel() + wait.resolve() + await Promise.resolve() + expect(sendFrame).not.toHaveBeenCalled() + expect( + streams.handleResponse(response('request-1', { type: 'ready', subscriptionId: 'server-1' })) + ).toBe(false) + }) + + it.each([false, true])( + 'preserves a same-worktree sibling when cancellation precedes snapshot=%s', + async (early) => { + const { streams, sendFrame } = createStreams() + const first = vi.fn() + const second = vi.fn() + const cancel = streams.subscribe( + 'session.tabs.subscribe', + { worktree: 'id:workspace' }, + first + ) + streams.subscribe('session.tabs.subscribe', { worktree: 'id:workspace' }, second) + await Promise.resolve() + if (early) { + cancel() + } + expect(sendFrame).toHaveBeenCalledTimes(2) + streams.handleResponse(response('request-1', { type: 'snapshot' })) + if (!early) { + cancel() + } + expect(sendFrame).toHaveBeenLastCalledWith({ + id: 'request-3', + method: 'session.tabs.unsubscribe', + params: { worktree: 'id:workspace', subscriptionId: 'request-1' } + }) + streams.handleResponse(response('request-2', { type: 'snapshot' })) + streams.handleResponse(response('request-2', { type: 'updated' })) + expect(second).toHaveBeenCalledTimes(2) + expect(first).toHaveBeenCalledTimes(early ? 0 : 1) + expect(streams.handleResponse(response('request-1', { type: 'updated' }))).toBe(false) + } + ) + + it.each([ + ['nativeChat.subscribe', { agent: 'claude', sessionId: 's1', subscriptionId: 'claude:s1' }], + ['terminal.subscribe', { terminal: 'term', client: { id: 'phone' } }] + ])( + 'keeps the newer %s live when an older same-token subscription unmounts', + async (method, params) => { + const { streams, sendFrame } = createStreams() + const older = vi.fn() + const newer = vi.fn() + const cancelOlder = streams.subscribe(method, params, older) + const cancelNewer = streams.subscribe(method, { ...params }, newer) + await Promise.resolve() + expect(sendFrame).toHaveBeenCalledTimes(2) + cancelOlder() + // The host keys cleanup by the deterministic token, so unsubscribing would evict the newer. + expect(sendFrame).toHaveBeenCalledTimes(2) + streams.handleResponse(response('request-2', { type: 'snapshot' })) + expect(newer).toHaveBeenCalledTimes(1) + expect(streams.handleResponse(response('request-1', { type: 'snapshot' }))).toBe(false) + expect(older).not.toHaveBeenCalled() + cancelNewer() + expect(sendFrame).toHaveBeenCalledTimes(3) + expect(sendFrame).toHaveBeenLastCalledWith( + expect.objectContaining({ method: method.replace(/\.subscribe$/, '.unsubscribe') }) + ) + } + ) + + it('still unsubscribes a shared-token nativeChat stream when the sibling is unsent', async () => { + const wait = Promise.withResolvers() + let connected = false + const { streams, sendFrame } = createStreams(() => + connected ? Promise.resolve() : wait.promise + ) + const params = { agent: 'claude', sessionId: 's1', subscriptionId: 'claude:s1' } + connected = true + const cancelOlder = streams.subscribe('nativeChat.subscribe', params, vi.fn()) + await Promise.resolve() + connected = false + streams.subscribe('nativeChat.subscribe', params, vi.fn()) + cancelOlder() + expect(sendFrame).toHaveBeenCalledTimes(2) + expect(sendFrame).toHaveBeenLastCalledWith({ + id: 'request-3', + method: 'nativeChat.unsubscribe', + params: { subscriptionId: 'claude:s1' } + }) + }) + + it('cleans up every cancelled server subscription across repeated late-ready cycles', async () => { + const { streams, sendFrame } = createStreams() + const listener = vi.fn() + for (let i = 0; i < 100; i++) { + const cancel = streams.subscribe('runtime.clientEvents.subscribe', {}, listener) + await Promise.resolve() + const requestId = `request-${2 * i + 1}` + cancel() + streams.handleResponse(response(requestId, { type: 'ready', subscriptionId: `server-${i}` })) + } + expect( + sendFrame.mock.calls.filter( + ([request]) => (request as { method: string }).method === 'runtime.clientEvents.unsubscribe' + ) + ).toHaveLength(100) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-rpc-streams.ts b/mobile/src/transport/mobile-relay-rpc-streams.ts index 2eacbd2168f..abb2c12564b 100644 --- a/mobile/src/transport/mobile-relay-rpc-streams.ts +++ b/mobile/src/transport/mobile-relay-rpc-streams.ts @@ -4,9 +4,11 @@ import { type TerminalSnapshotState } from './rpc-client-terminal-binary-frame' import { + buildStreamUnsubscribe, buildTerminalUnsubscribeParams, updateTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' +import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription' import type { RpcClient } from './rpc-client' import type { RpcResponse, RpcSuccess } from './types' @@ -22,6 +24,23 @@ type StreamRecord = { streamIds: Set subscriptionId?: string cancelled: boolean + sent: boolean + receivedSnapshot?: boolean +} + +type StreamUnsubscribe = { method: string; params: unknown } + +/** Unsubscribe derived from the subscribe params alone (no server-assigned id). */ +function buildParamsUnsubscribe( + method: string, + params: unknown, + requestId: string +): StreamUnsubscribe | null { + if (method === 'terminal.subscribe') { + const unsubscribeParams = buildTerminalUnsubscribeParams(params) + return unsubscribeParams ? { method: 'terminal.unsubscribe', params: unsubscribeParams } : null + } + return buildStreamUnsubscribe(method, params, requestId) } type StreamManagerOptions = { @@ -32,6 +51,10 @@ type StreamManagerOptions = { export class MobileRelayRpcStreams { private readonly streams = new Map() + private readonly cancelledSubscriptions = new Map< + string, + { method: string; unsubscribe?: StreamUnsubscribe } + >() private readonly terminalListeners = new Map void>() private readonly terminalSnapshots = new Map() private activeBrowserStream: StreamRecord | null = null @@ -51,13 +74,15 @@ export class MobileRelayRpcStreams { listener, onBinaryFrame: subscribeOptions?.onBinaryFrame, streamIds: new Set(), - cancelled: false + cancelled: false, + sent: false } this.streams.set(id, stream) void this.options .waitForConnected() .then(() => { if (!stream.cancelled) { + stream.sent = true if (!this.options.sendFrame({ id, method, params: stream.params })) { this.fail(id, stream, 'Connection interrupted') } @@ -75,6 +100,30 @@ export class MobileRelayRpcStreams { } handleResponse(response: RpcResponse): boolean { + const cancelled = this.cancelledSubscriptions.get(response.id) + if (cancelled) { + if (!response.ok) { + this.cancelledSubscriptions.delete(response.id) + } else if (response.result && typeof response.result === 'object') { + const result = response.result as { subscriptionId?: unknown; type?: unknown } + if (result.type === 'end') { + this.cancelledSubscriptions.delete(response.id) + } else if (result.type === 'snapshot' && cancelled.unsubscribe) { + this.cancelledSubscriptions.delete(response.id) + this.options.sendFrame({ id: this.options.nextId(), ...cancelled.unsubscribe }) + } else if (typeof result.subscriptionId === 'string') { + this.cancelledSubscriptions.delete(response.id) + const unsubscribe = buildReadyStreamUnsubscribe(cancelled.method, result.subscriptionId) + if (unsubscribe) { + this.options.sendFrame({ id: this.options.nextId(), ...unsubscribe }) + } + } + } + if (response.ok && response.streaming !== true) { + this.cancelledSubscriptions.delete(response.id) + } + return true + } const stream = this.streams.get(response.id) if (!stream) { return false @@ -86,6 +135,9 @@ export class MobileRelayRpcStreams { const result = (response as RpcSuccess).result if (result && typeof result === 'object') { const metadata = result as { subscriptionId?: unknown; streamId?: unknown; type?: unknown } + if (stream.method === 'session.tabs.subscribe' && metadata.type === 'snapshot') { + stream.receivedSnapshot = true + } if (typeof metadata.subscriptionId === 'string') { stream.subscriptionId = metadata.subscriptionId } @@ -125,6 +177,7 @@ export class MobileRelayRpcStreams { stream.cancelled = true } this.streams.clear() + this.cancelledSubscriptions.clear() this.terminalListeners.clear() this.terminalSnapshots.clear() this.activeBrowserStream = null @@ -136,25 +189,61 @@ export class MobileRelayRpcStreams { return } stream.cancelled = true - if (stream.method === 'terminal.subscribe') { - const params = buildTerminalUnsubscribeParams(stream.params) - if (params) { - this.options.sendFrame({ - id: this.options.nextId(), - method: 'terminal.unsubscribe', - params - }) + if (stream.sent) { + const byParams = buildParamsUnsubscribe(stream.method, stream.params, id) + if (stream.method === 'terminal.subscribe') { + if (byParams) { + this.sendUnsubscribe(byParams) + } + } else { + const unsubscribe = stream.subscriptionId + ? buildReadyStreamUnsubscribe(stream.method, stream.subscriptionId) + : null + if (byParams && stream.method === 'session.tabs.subscribe' && !stream.receivedSnapshot) { + // The host registers cleanup only after resolving the initial snapshot. + this.cancelledSubscriptions.set(id, { method: stream.method, unsubscribe: byParams }) + } else if (unsubscribe || byParams) { + this.sendUnsubscribe((unsubscribe ?? byParams)!) + } else if ( + stream.method === 'browser.screencast' || + stream.method === 'runtime.clientEvents.subscribe' + ) { + // Keep only the cleanup route while the server assigns its subscription ID. + this.cancelledSubscriptions.set(id, { method: stream.method }) + } else if (stream.subscriptionId) { + this.sendUnsubscribe({ + method: stream.method.replace(/\.subscribe$/, '.unsubscribe'), + params: { subscriptionId: stream.subscriptionId } + }) + } } - } else if (stream.subscriptionId) { - this.options.sendFrame({ - id: this.options.nextId(), - method: stream.method.replace(/\.subscribe$/, '.unsubscribe'), - params: { subscriptionId: stream.subscriptionId } - }) } this.remove(id) } + /** Skip the unsubscribe when a live sibling shares the host cleanup token (e.g. nativeChat's + * deterministic `agent:sessionId`), since the host would evict the sibling's registration. */ + private sendUnsubscribe(unsubscribe: StreamUnsubscribe): void { + if (this.hasLiveOwner(unsubscribe)) { + return + } + this.options.sendFrame({ id: this.options.nextId(), ...unsubscribe }) + } + + private hasLiveOwner(unsubscribe: StreamUnsubscribe): boolean { + const token = JSON.stringify(unsubscribe) + for (const [siblingId, sibling] of this.streams) { + if (sibling.cancelled || !sibling.sent) { + continue + } + const siblingUnsubscribe = buildParamsUnsubscribe(sibling.method, sibling.params, siblingId) + if (siblingUnsubscribe && JSON.stringify(siblingUnsubscribe) === token) { + return true + } + } + return false + } + private remove(id: string): void { const stream = this.streams.get(id) if (!stream) { diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 471bc3c4a4e..405f7f1d9e0 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -38,7 +38,8 @@ export function updateTerminalSubscriptionViewport( * the per-method echo logic out of the rpc-client teardown closure. */ export function buildStreamUnsubscribe( method: string | undefined, - params: unknown + params: unknown, + requestId?: string ): { method: string; params: Record } | null { if (!params || typeof params !== 'object') { return null @@ -46,7 +47,10 @@ export function buildStreamUnsubscribe( if (method === 'session.tabs.subscribe') { const worktree = (params as { worktree?: unknown }).worktree return typeof worktree === 'string' - ? { method: 'session.tabs.unsubscribe', params: { worktree } } + ? { + method: 'session.tabs.unsubscribe', + params: { worktree, ...(requestId ? { subscriptionId: requestId } : {}) } + } : null } if (method === 'nativeChat.subscribe') { diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 4b68525c7c6..efd44e6cde7 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -1,9 +1,15 @@ param( - [Parameter(Mandatory = $true)] - [string]$OperationPath + [Parameter(Position = 0)] + [string]$OperationPath, + # Serve mode keeps one process alive so the Add-Type P/Invoke assembly below + # is emitted once per session instead of once per operation. + [switch]$Serve ) $ErrorActionPreference = "Stop" +# Progress records render to the host, which in serve mode is a pipe carrying +# one JSON response per line; a stray record would desynchronise the stream. +$ProgressPreference = "SilentlyContinue" $utf8NoBom = New-Object System.Text.UTF8Encoding $false [Console]::InputEncoding = $utf8NoBom [Console]::OutputEncoding = $utf8NoBom @@ -1313,9 +1319,56 @@ function Invoke-OrcaOperation($Operation) { [pscustomobject]@{ ok = $true; action = $action; snapshot = $snapshot } } -try { - $operation = Read-OrcaOperation $OperationPath - Write-OrcaJson (Invoke-OrcaOperation $operation) -} catch { - Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message }) +function Invoke-OrcaServeLoop { + # Announced before the first read, and after every Add-Type above: a caller + # that never sees this line knows the helper cannot have read a request, let + # alone synthesized a click, so replaying it is provably safe. Inferring that + # from a missing response instead would replay operations that did run. + [Console]::Out.WriteLine('{"ready":true}') + [Console]::Out.Flush() + # One NDJSON request per line in, one response per line out, until stdin closes. + # Responses carry base64 screenshots and routinely exceed a megabyte; ReadLine + # and the console writer are both length-bounded only by memory. + while ($true) { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { break } + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $requestId = $null + try { + $operation = $line | ConvertFrom-Json + $requestId = $operation.requestId + $response = Invoke-OrcaOperation $operation + } catch { + $response = [pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message } + # ConvertFrom-Json throws before the id is read, so recover it from the + # raw line. An error the caller can match is delivered to the request + # that caused it; an unmatched one only trips the caller's desync + # guard, which kills this helper, charges a failure toward its cooldown + # and discards the message below - so a malformed request would be + # reported as a broken stream and its real cause never surface. + if ($null -eq $requestId -and $line -match '"requestId"\s*:\s*(\d+)') { + $requestId = [long]$Matches[1] + } + } + # Echoed so the caller can prove which request a line answers; a reply it + # cannot match is a desynchronised stream, not a usable response. + if ($null -ne $requestId) { + $response | Add-Member -NotePropertyName requestId -NotePropertyValue $requestId -Force + } + [Console]::Out.WriteLine((ConvertTo-Json $response -Depth 100 -Compress)) + [Console]::Out.Flush() + } +} + +if ($Serve) { + Invoke-OrcaServeLoop +} elseif ([string]::IsNullOrWhiteSpace($OperationPath)) { + Write-OrcaJson ([pscustomobject]@{ ok = $false; error = "runtime.ps1 requires an operation path or -Serve" }) +} else { + try { + $operation = Read-OrcaOperation $OperationPath + Write-OrcaJson (Invoke-OrcaOperation $operation) + } catch { + Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message }) + } } diff --git a/package.json b/package.json index 21636f94e9c..7b27dffc8c7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "main": "./out/main/index.js", "scripts": { + "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", + "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", @@ -144,7 +146,6 @@ "bench:agent-inspection-cadence": "node config/scripts/agent-inspection-cadence-batching-benchmark.mjs", "bench:renderer-quadratic-scans": "node config/scripts/renderer-quadratic-scan-benchmark.mjs", "bench:session-write-hot-path": "node config/scripts/session-write-hot-path-benchmark.mjs", - "bench:terminal-partial-escape-tail": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/terminal-partial-escape-tail-benchmark.mjs", "bench:terminal-partial-escape-tail": "node config/scripts/terminal-partial-escape-tail-benchmark.mjs", "bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs", "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b59d23e026..a69e47f89b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,7 +109,7 @@ overrides: monaco-editor>dompurify: 3.4.13 patchedDependencies: - '@vscode/windows-process-tree@0.8.0': 9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585 + '@vscode/windows-process-tree@0.8.0': f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294 @@ -510,7 +510,7 @@ importers: optionalDependencies: '@vscode/windows-process-tree': specifier: 0.8.0 - version: 0.8.0(patch_hash=9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585) + version: 0.8.0(patch_hash=f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e) sherpa-onnx-darwin-arm64: specifier: 1.12.37 version: 1.12.37 @@ -9821,7 +9821,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode/windows-process-tree@0.8.0(patch_hash=9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585)': + '@vscode/windows-process-tree@0.8.0(patch_hash=f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e)': dependencies: node-addon-api: 7.1.0 optional: true diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 0878532c447..eab866f13d0 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -180,6 +180,15 @@ Dispatch rules: - After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed. - Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag. +`dispatch` and `worker-start` refuse the following preflight cases with a stable `error.code`; read it before choosing a recovery, and treat `error.data.nextSteps` as the exact recovery text. Older hosts may omit `data`, so treat every field as optional. + +| Code | Meaning | Recovery | +| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist | +| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched | +| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` | +| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged | + ## How deep workers can nest A dispatched worker normally cannot dispatch sub-workers. Attempting it fails with diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 06aae7bdd7d..5e68efbe8da 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\nKeep Orca's checkout behavior unchanged by default: omit `checkoutMode`, emit schema version 1, and\nlet Orca create a linked worktree. Only use `checkoutMode: provisioned-root` when the user explicitly\nwants one ephemeral machine to clone the finished workspace itself. This niche mode currently requires\ndirect SSH, an ordinary non-bare/non-sparse primary checkout at `projectRoot`, and schema version 2.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Checkout ownership:** do not ask by default. Only when the user requires the environment to\n create the exact final checkout, confirm `provisioned-root` and direct SSH; otherwise omit it.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\ntoken`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- **Never snapshot a machine on which the Orca runtime has already run.** The first `orca serve` creates\n the runtime's user-data dir, and everything in it gets baked into the image and shared by every VM\n booted from it: the pairing keypair and device-token registry (`orca-devices.json`,\n `orca-e2ee-keypair.json`), `agent-session-authority.key`, and the build box's logs, terminal history\n and orchestration db. Confirmed: two VMs from one such snapshot emitted **identical `deviceToken` and\n `pairedDeviceId`**. Snapshot **before** the runtime has ever run, or delete the resolved user-data\n directory first: `orca_user_data_path=\"${ORCA_USER_DATA_PATH:-${XDG_CONFIG_HOME:-$HOME/.config}/orca}\"; rm -rf -- \"$orca_user_data_path\"`.\n This matches Orca's Linux precedence for custom and default paths; deleting a named file list will\n drift as Orca adds state.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nThis layer inherits §3's rule: if you started `orca serve` on the base or auth sandbox to smoke-test it,\ndelete the runtime's user-data dir (`~/.config/orca` on Linux) before re-snapshotting, or every workspace\nbooted from this image shares one pairing identity and one `agent-session-authority.key`.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\nbash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` _inside_ the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"\",\n \"projectRoot\": \"\"\n}\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nrecipe_id=\"${ORCA_RECIPE_ID:-vercel-sandbox}\"\nrecipe_id=\"${recipe_id//./-}\" # Vercel names forbid dots.\ninstance_id=\"${ORCA_VM_INSTANCE_ID:-$(date +%s)}\"\nmax_recipe_id_length=$((128 - ${#instance_id} - 6)) # Preserve the unique instance suffix.\n[ \"$max_recipe_id_length\" -gt 0 ] || { echo \"ORCA_VM_INSTANCE_ID is too long for a Vercel sandbox name\" >&2; exit 1; }\nname=\"orca-${recipe_id:0:max_recipe_id_length}-${instance_id}\"\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\nFor an explicitly requested one-VM-per-workspace checkout, the create script must read\n`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and\n`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create\n`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race\nwith an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when\nthe desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the\nsame SSH result with:\n\n```bash\n[ -n \"${ORCA_REPO_REF_HEAD:-}\" ] || { echo \"missing pinned source commit\" >&2; exit 1; }\ngit fetch origin \"$ORCA_REPO_REF\"\ngit cat-file -e \"${ORCA_REPO_REF_HEAD}^{commit}\"\ngit checkout -B \"$ORCA_REPO_BRANCH\" \"$ORCA_REPO_REF_HEAD\"\n```\n\n```json\n{\n \"schemaVersion\": 2,\n \"checkoutMode\": \"provisioned-root\",\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/repo\",\n \"target\": { \"label\": \"my-box\", \"host\": \"192.0.2.10\", \"port\": 22, \"username\": \"ubuntu\" }\n }\n}\n```\n\nFail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.\n\n**Networking → which `target` fields to set** (how _your desktop_ reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host _is_ the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run _inside_ the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\n**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add\n`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create\nthe requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only\nto fetch that commit) at the returned `projectRoot`, and emit schema version 2 with\n`checkoutMode: \"provisioned-root\"`. All recipes without this field retain the schema-v1 behavior above.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [{ \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" }],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`\n instead for full ownership handoffs, including requests phrased as \"hand\n off\", \"handoff\", \"handover\", \"give this to another agent\", or \"another\n worktree\" when the user did not explicitly ask to supervise, monitor, wait\n for results, or coordinate a DAG. Use `orca-cli` for terminal control,\n lightweight terminal prompts, shell commands, Orca worktree management,\n reading or waiting on terminals, and the Orca embedded browser. Use Computer\n Use for external browser windows, webviews, Orca app UI, or desktop UI\n outside Orca's embedded browser only when the task requires OS/window-level\n control such as focus, menus, dialogs, coordinates, or screenshots. Use\n `orca-cli` for Orca's embedded pages and a page-automation tool such as\n Playwright or CDP for external pages.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- `check --json` prints exactly one JSON document on stdout. While `--wait` blocks it also prints keepalive lines (`{\"_keepalive\":true,...}`) to stderr so you can tell the process is alive; those are never on stdout. Do not merge the streams before a parser — `check --wait --json 2>&1 | ` fails with \"Extra data: line 2\". Pipe stdout only.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## How deep workers can nest\n\nA dispatched worker normally cannot dispatch sub-workers. Attempting it fails with\n`nested_worker_depth_exceeded` and a message telling the worker to complete the task\nitself. Do that — do not try to route around it.\n\nThe limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`\nsets how many generations are allowed:\n\n- `1` (default): a coordinator dispatches workers; those workers do not dispatch.\n- `2`: workers may dispatch one further generation.\n\nDepth is counted from the terminal that issues the command, not from the Run. Creating a\nnew Run does not reset it — a worker that runs `run-create` then `worker-start` is still a\nworker, and still counted. This is the part that changed: the old behaviour rejected\nsub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was\nenough to slip past it.\n\nTwo limits worth knowing:\n\n- **It is a guardrail, not a security boundary.** A caller that declares another terminal's\n handle while its own launch evidence is unverifiable (an ordinary restored terminal, for\n example) can be counted as that terminal instead. Orca does not treat workers as hostile.\n- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator\n settles the task, the terminal is no longer a worker and is counted as a root again. The\n process may still be alive; that is the documented boundary, not an accident.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- The response was lost and named no Dispatch: run `orca orchestration request-show --request --json` first. It is read-only. `completed` means the mutation already took effect. `pending` means the original mutation is still running or Orca restarted before recording its outcome. For either state, replaying the original command with `--retry-request ` reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate. `absent` means this runtime holds no receipt under your caller identity and is not proof that nothing happened; inspect the affected state before deciding whether to retry.\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal ` when supervision and worker lifecycle state are required.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`\n instead for full ownership handoffs, including requests phrased as \"hand\n off\", \"handoff\", \"handover\", \"give this to another agent\", or \"another\n worktree\" when the user did not explicitly ask to supervise, monitor, wait\n for results, or coordinate a DAG. Use `orca-cli` for terminal control,\n lightweight terminal prompts, shell commands, Orca worktree management,\n reading or waiting on terminals, and the Orca embedded browser. Use Computer\n Use for external browser windows, webviews, Orca app UI, or desktop UI\n outside Orca's embedded browser only when the task requires OS/window-level\n control such as focus, menus, dialogs, coordinates, or screenshots. Use\n `orca-cli` for Orca's embedded pages and a page-automation tool such as\n Playwright or CDP for external pages.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- `check --json` prints exactly one JSON document on stdout. While `--wait` blocks it also prints keepalive lines (`{\"_keepalive\":true,...}`) to stderr so you can tell the process is alive; those are never on stdout. Do not merge the streams before a parser — `check --wait --json 2>&1 | ` fails with \"Extra data: line 2\". Pipe stdout only.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable `error.code`; read it before choosing a recovery, and treat `error.data.nextSteps` as the exact recovery text. Older hosts may omit `data`, so treat every field as optional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## How deep workers can nest\n\nA dispatched worker normally cannot dispatch sub-workers. Attempting it fails with\n`nested_worker_depth_exceeded` and a message telling the worker to complete the task\nitself. Do that — do not try to route around it.\n\nThe limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`\nsets how many generations are allowed:\n\n- `1` (default): a coordinator dispatches workers; those workers do not dispatch.\n- `2`: workers may dispatch one further generation.\n\nDepth is counted from the terminal that issues the command, not from the Run. Creating a\nnew Run does not reset it — a worker that runs `run-create` then `worker-start` is still a\nworker, and still counted. This is the part that changed: the old behaviour rejected\nsub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was\nenough to slip past it.\n\nTwo limits worth knowing:\n\n- **It is a guardrail, not a security boundary.** A caller that declares another terminal's\n handle while its own launch evidence is unverifiable (an ordinary restored terminal, for\n example) can be counted as that terminal instead. Orca does not treat workers as hostile.\n- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator\n settles the task, the terminal is no longer a worker and is counted as a root again. The\n process may still be alive; that is the documented boundary, not an accident.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- The response was lost and named no Dispatch: run `orca orchestration request-show --request --json` first. It is read-only. `completed` means the mutation already took effect. `pending` means the original mutation is still running or Orca restarted before recording its outcome. For either state, replaying the original command with `--retry-request ` reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate. `absent` means this runtime holds no receipt under your caller identity and is not proof that nothing happened; inspect the affected state before deciding whether to retry.\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal ` when supervision and worker lifecycle state are required.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts new file mode 100644 index 00000000000..6a87f149079 --- /dev/null +++ b/src/cli/cli-error.ts @@ -0,0 +1,144 @@ +import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery' +import { + matchAutomationOwnerConflict, + stripAutomationOwnerConflictCode +} from '../shared/automation-owner-conflict' +import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' +import type { RuntimeRpcFailure } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' + +type CliErrorContext = { + commandPath?: readonly string[] +} + +export function formatCliError(error: unknown, context: CliErrorContext = {}): string { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { + if (hasOrchestrationRequestId(error.data)) { + return message + } + return `${message}\nOrca is not running. Run 'orca open' first.` + } + // Why: error-specific recovery must win over the generic computer fallback. + // Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token. + const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error)) + if (conflict) { + return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps) + } + if (error instanceof RuntimeClientError) { + const nextSteps = nextStepsFromData(error.data) + if (nextSteps.length > 0) { + return formatMessageWithNextSteps(message, nextSteps) + } + if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') { + return formatMessageWithNextSteps( + message, + computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? [] + ) + } + } + if ( + error instanceof RuntimeRpcFailureError && + error.response.error.code === 'runtime_unavailable' + ) { + return `${message}\nOrca is not running. Run 'orca open' first.` + } + if (error instanceof RuntimeRpcFailureError) { + return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data)) + } + return message +} + +function hasOrchestrationRequestId(data: unknown): boolean { + return ( + data !== null && + typeof data === 'object' && + typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string' + ) +} + +export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void { + if (json) { + if (error instanceof RuntimeRpcFailureError) { + console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2)) + } else { + const response: RuntimeRpcFailure = { + id: 'local', + ok: false, + error: { + code: + matchAutomationOwnerConflict(error) ?? + (error instanceof RuntimeClientError ? error.code : 'runtime_error'), + message: stripAutomationOwnerConflictCode( + error instanceof Error ? error.message : String(error) + ), + data: localCliErrorData(error, context) + }, + _meta: { + runtimeId: null + } + } + console.log(JSON.stringify(response, null, 2)) + } + } else { + console.error(formatCliError(error, context)) + } +} + +/** Machine-readable half of the same recovery the human message carries. */ +function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure { + const code = matchAutomationOwnerConflict(response) + const conflict = automationOwnerConflictRecovery(code) + if (!conflict || !code) { + return response + } + return { + ...response, + error: { + ...response.error, + // Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport. + code, + message: stripAutomationOwnerConflictCode(response.error.message), + data: response.error.data ?? conflict + } + } +} + +function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string { + if (nextSteps.length === 0) { + return message + } + return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}` +} + +function nextStepsFromData(data: unknown): string[] { + if ( + data && + typeof data === 'object' && + Array.isArray((data as { nextSteps?: unknown }).nextSteps) + ) { + return (data as { nextSteps: unknown[] }).nextSteps.filter( + (step): step is string => typeof step === 'string' + ) + } + return [] +} + +function localCliErrorData(error: unknown, context: CliErrorContext): unknown { + // Why: error-specific recovery must win over the generic computer fallback. + if (error instanceof RuntimeClientError && error.data !== undefined) { + return error.data + } + const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error)) + if (conflict) { + return conflict + } + if ( + error instanceof RuntimeClientError && + error.code === 'invalid_argument' && + context.commandPath?.[0] === 'computer' + ) { + return computerUseErrorRecoveryData('invalid_argument') + } + return undefined +} diff --git a/src/cli/command-suggestion-budget.test.ts b/src/cli/command-suggestion-budget.test.ts new file mode 100644 index 00000000000..7902ad7fdba --- /dev/null +++ b/src/cli/command-suggestion-budget.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as distance from '../shared/edit-distance' +import { suggestCommands, unknownFlagData } from './command-suggestion' +import type { CommandSpec } from './command-spec' + +const specs: CommandSpec[] = [ + { path: ['list'], summary: '', usage: '', allowedFlags: [] }, + { path: ['remove'], summary: '', usage: '', allowedFlags: [], destructive: true } +] + +afterEach(() => vi.restoreAllMocks()) + +describe('suggestion distance work', () => { + it('does no distance calculations for a long command, including destructive intent', () => { + const spy = vi.spyOn(distance, 'levenshtein') + expect(suggestCommands(specs, ['x'.repeat(32_768)])).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) + + it('does no distance calculations for a long flag but still lists valid flags', () => { + const spy = vi.spyOn(distance, 'levenshtein') + expect(unknownFlagData('x'.repeat(32_768), ['worktree', 'json'])).toEqual({ + validFlags: ['json', 'worktree'], + suggestions: [], + nextSteps: ['Valid flags: --json, --worktree'] + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('keeps the inclusive three-edit suggestion boundary', () => { + expect(suggestCommands(specs, ['listxxx'])).toEqual(['list']) + expect(unknownFlagData('jsonxxx', ['json']).suggestions).toEqual(['json']) + }) + + it('keeps the inclusive one-edit destructive intent boundary', () => { + expect(suggestCommands(specs, ['remov'])).toEqual(['remove']) + expect(suggestCommands(specs, ['remo'])).toEqual([]) + }) + + it('retains UTF-16 distance semantics at the length boundary', () => { + expect(unknownFlagData('json😀x', ['json']).suggestions).toEqual(['json']) + expect(unknownFlagData('json😀😀', ['json']).suggestions).toEqual([]) + }) +}) diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index 7b80138e2f3..9c935f694fa 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -37,7 +37,10 @@ function destructiveVerbs(specs: CommandSpec[]): Set { // input token is itself a near-miss of a destructive verb. #6303 function intendsDestruction(inputToken: string, verbs: Set): boolean { for (const verb of verbs) { - if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) { + if ( + Math.abs(inputToken.length - verb.length) <= DESTRUCTIVE_INTENT_THRESHOLD && + levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD + ) { return true } } @@ -85,7 +88,9 @@ export function suggestCommands(specs: CommandSpec[], commandPath: string[]): st continue } seen.add(joined) - scored.push({ label: joined, distance: levenshtein(input, joined) }) + if (Math.abs(input.length - joined.length) <= SUGGESTION_THRESHOLD) { + scored.push({ label: joined, distance: levenshtein(input, joined) }) + } } } return rankByDistance(scored) @@ -106,9 +111,13 @@ export type FlagErrorData = { } function suggestFlags(flag: string, validFlags: string[]): string[] { - return rankByDistance( - validFlags.map((candidate) => ({ label: candidate, distance: levenshtein(flag, candidate) })) - ) + const scored: { label: string; distance: number }[] = [] + for (const candidate of validFlags) { + if (Math.abs(flag.length - candidate.length) <= SUGGESTION_THRESHOLD) { + scored.push({ label: candidate, distance: levenshtein(flag, candidate) }) + } + } + return rankByDistance(scored) } // Why: include the accepted set so agents can recover without another help call. diff --git a/src/cli/format.ts b/src/cli/format.ts index 0a297138364..dd6b7b739c7 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,13 +1,8 @@ import type { CliStatusResult } from '../shared/runtime-types' -import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery' -import { - matchAutomationOwnerConflict, - stripAutomationOwnerConflictCode -} from '../shared/automation-owner-conflict' -import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' import { prepareComputerCliJsonResult } from './computer-format' -import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' -import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' +import type { RuntimeRpcSuccess } from './runtime-client' + +export { formatCliError, reportCliError } from './cli-error' export { formatBrowserProfileList, @@ -67,10 +62,6 @@ export { formatWorktreeShow } from './workspace-format' -type CliErrorContext = { - commandPath?: readonly string[] -} - export function printResult( response: RuntimeRpcSuccess, json: boolean, @@ -83,143 +74,14 @@ export function printResult( console.log(formatter(response.result)) } -export function formatCliError(error: unknown, context: CliErrorContext = {}): string { - const message = error instanceof Error ? error.message : String(error) - if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { - if (hasOrchestrationRequestId(error.data)) { - return message - } - return `${message}\nOrca is not running. Run 'orca open' first.` - } - // Why: error-specific recovery must win over the generic computer fallback. - // Classified from the whole error, not just `.code`: a hop that flattens the class leaves only the token. - const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error)) - if (conflict) { - return formatMessageWithNextSteps(stripAutomationOwnerConflictCode(message), conflict.nextSteps) - } - if (error instanceof RuntimeClientError) { - const nextSteps = nextStepsFromData(error.data) - if (nextSteps.length > 0) { - return formatMessageWithNextSteps(message, nextSteps) - } - if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') { - return formatMessageWithNextSteps( - message, - computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? [] - ) - } - } - if ( - error instanceof RuntimeRpcFailureError && - error.response.error.code === 'runtime_unavailable' - ) { - return `${message}\nOrca is not running. Run 'orca open' first.` - } - if (error instanceof RuntimeRpcFailureError) { - return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data)) - } - return message -} - -function hasOrchestrationRequestId(data: unknown): boolean { - return ( - data !== null && - typeof data === 'object' && - typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string' - ) -} - -export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void { - if (json) { - if (error instanceof RuntimeRpcFailureError) { - console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2)) - } else { - const response: RuntimeRpcFailure = { - id: 'local', - ok: false, - error: { - code: - matchAutomationOwnerConflict(error) ?? - (error instanceof RuntimeClientError ? error.code : 'runtime_error'), - message: stripAutomationOwnerConflictCode( - error instanceof Error ? error.message : String(error) - ), - data: localCliErrorData(error, context) - }, - _meta: { - runtimeId: null - } - } - console.log(JSON.stringify(response, null, 2)) - } - } else { - console.error(formatCliError(error, context)) - } -} - -/** Machine-readable half of the same recovery the human message carries. */ -function withAutomationOwnerConflictRecovery(response: RuntimeRpcFailure): RuntimeRpcFailure { - const code = matchAutomationOwnerConflict(response) - const conflict = automationOwnerConflictRecovery(code) - if (!conflict || !code) { - return response - } - return { - ...response, - error: { - ...response.error, - // Restores the classification a flattening hop dropped, so --json consumers read the conflict, not the transport. - code, - message: stripAutomationOwnerConflictCode(response.error.message), - data: response.error.data ?? conflict - } - } -} - -function formatMessageWithNextSteps(message: string, nextSteps: readonly string[]): string { - if (nextSteps.length === 0) { - return message - } - return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}` -} - -function nextStepsFromData(data: unknown): string[] { - if ( - data && - typeof data === 'object' && - Array.isArray((data as { nextSteps?: unknown }).nextSteps) - ) { - return (data as { nextSteps: unknown[] }).nextSteps.filter( - (step): step is string => typeof step === 'string' - ) - } - return [] -} - -function localCliErrorData(error: unknown, context: CliErrorContext): unknown { - // Why: error-specific recovery must win over the generic computer fallback. - if (error instanceof RuntimeClientError && error.data !== undefined) { - return error.data - } - const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error)) - if (conflict) { - return conflict - } - if ( - error instanceof RuntimeClientError && - error.code === 'invalid_argument' && - context.commandPath?.[0] === 'computer' - ) { - return computerUseErrorRecoveryData('invalid_argument') - } - return undefined -} - export type HostListEntry = { kind: 'local' | 'ssh' | 'environment' name: string id: string selector: string + platform?: string + connected?: boolean + connectionStatus?: string } // Why: the selector column is the point of this command — the name alone is what callers already @@ -231,10 +93,25 @@ export function formatHostList(result: { hosts: HostListEntry[] }): string { environment: 'orca server' } return result.hosts - .map((host) => `${kindLabel[host.kind].padEnd(11)} ${host.name} -> ${host.selector}`) + .map( + (host) => + `${kindLabel[host.kind].padEnd(11)} ${host.name} ${host.platform ?? 'platform unknown'} ${formatHostConnection(host)} -> ${host.selector}` + ) .join('\n') } +function formatHostConnection(host: HostListEntry): string { + if (host.kind !== 'ssh') { + return '' + } + if (host.connected === undefined) { + return `connection unknown${host.connectionStatus ? ` (${host.connectionStatus})` : ''}` + } + return host.connected + ? `connected${host.connectionStatus ? ` (${host.connectionStatus})` : ''}` + : `not connected${host.connectionStatus ? ` (${host.connectionStatus})` : ''}` +} + export function formatCliStatus(status: CliStatusResult): string { return [ ...(status.target && status.target.kind === 'environment' diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 4fcc186b0d8..279a8900bec 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -56,7 +56,10 @@ vi.mock('../runtime-client', () => { vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({ applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock, - getManagedAgentHookStatuses: getManagedAgentHookStatusesMock, + getManagedAgentHookStatuses: getManagedAgentHookStatusesMock +})) + +vi.mock('../../main/codex/managed-home-shell-preflight', () => ({ prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeBeforeShellLaunchMock })) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index bf44211b4a7..4fcfe64f9b7 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -15,11 +15,7 @@ import { getDefaultPersistedState } from '../../shared/constants' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { PersistedState } from '../../shared/persisted-state-types' -import { - applyAgentStatusHooksEnabled, - getManagedAgentHookStatuses, - prepareManagedCodexHomeBeforeShellLaunch -} from '../../main/agent-hooks/managed-agent-hook-controls' +import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight' type AgentHookCommandResult = { enabled: boolean @@ -194,6 +190,8 @@ async function setAgentHooksEnabled( client: RuntimeClient, enabled: boolean ): Promise { + const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } = + await import('../../main/agent-hooks/managed-agent-hook-controls.js') const updatedRuntime = await updateRunningRuntime(client, enabled) const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled) const settingsPath = offlineUpdate?.settingsPath ?? getDataPath() @@ -234,6 +232,8 @@ export const AGENT_HOOK_HANDLERS: Record = { }) }, 'agent hooks status': async ({ json }) => { + const { getManagedAgentHookStatuses } = + await import('../../main/agent-hooks/managed-agent-hook-controls.js') const result: AgentHookCommandResult = { enabled: readHookSettingsFromDisk().agentStatusHooksEnabled, settingsPath: getDataPath(), diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 37b437af2c3..181b2947f98 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -50,10 +50,19 @@ export const ENVIRONMENT_HANDLERS: Record = { kind: 'ssh' as const, name: target.label, id: target.id, - selector: `--host ssh:${target.id}` + selector: `--host ssh:${target.id}`, + ...(target.connected === undefined ? {} : { connected: target.connected }), + ...(target.connectionStatus ? { connectionStatus: target.connectionStatus } : {}), + ...(target.remotePlatform ? { platform: target.remotePlatform } : {}) })) const hosts = [ - { kind: 'local' as const, name: 'this machine', id: 'local', selector: '--host local' }, + { + kind: 'local' as const, + name: 'this machine', + id: 'local', + selector: '--host local', + platform: process.platform + }, ...sshTargets, ...environments ] diff --git a/src/cli/host-selector-alternatives.test.ts b/src/cli/host-selector-alternatives.test.ts index bc4fadd05c1..9460931a083 100644 --- a/src/cli/host-selector-alternatives.test.ts +++ b/src/cli/host-selector-alternatives.test.ts @@ -118,6 +118,24 @@ describe('listSshTargets', () => { expect(call).toHaveBeenCalledWith('ssh.listTargets') }) + it('enriches legacy target rows from host-owned connection state', async () => { + const { RuntimeClientError } = await import('./runtime/types.js') + const call = vi.fn(async (method: string) => { + if (method === 'ssh.listTargetSummaries') { + throw new RuntimeClientError('method_not_found', 'Unknown method') + } + if (method === 'ssh.getState') { + return { result: { state: { status: 'connected', remotePlatform: 'win32' } } } + } + return { result: { targets: SSH_TARGETS } } + }) + + await expect(listSshTargets({ call } as unknown as RuntimeClient)).resolves.toEqual([ + { ...SSH_TARGETS[0], connected: true, connectionStatus: 'connected', remotePlatform: 'win32' } + ]) + expect(call).toHaveBeenCalledWith('ssh.getState', { targetId: SSH_TARGETS[0].id }) + }) + // Why: this only ever runs to enrich an error we are already reporting; a failure here must // not replace that error with a confusing one about SSH enumeration. it('returns nothing rather than masking the error it was enriching', async () => { diff --git a/src/cli/host-selector-alternatives.ts b/src/cli/host-selector-alternatives.ts index fd5abecbc18..f42fec88aee 100644 --- a/src/cli/host-selector-alternatives.ts +++ b/src/cli/host-selector-alternatives.ts @@ -1,6 +1,12 @@ import type { RuntimeClient } from './runtime-client' -export type SshTargetSummary = { id: string; label: string } +export type SshTargetSummary = { + id: string + label: string + remotePlatform?: 'linux' | 'darwin' | 'win32' + connected?: boolean + connectionStatus?: string +} export type EnvironmentSummary = { id: string; name: string } export type HostAlternatives = { @@ -106,7 +112,7 @@ export async function listSshTargets(client: RuntimeClient): Promise('ssh.listTargets') - return legacy.result.targets + return await enrichLegacySshTargetStates(client, legacy.result.targets) } catch { return [] } @@ -115,6 +121,34 @@ export async function listSshTargets(client: RuntimeClient): Promise { + return Promise.all( + targets.map(async (target) => { + try { + const response = await client.call<{ + state: { + status?: string + remotePlatform?: 'linux' | 'darwin' | 'win32' + } | null + }>('ssh.getState', { targetId: target.id }) + const state = response.result.state + return { + ...target, + ...(state?.status === undefined + ? {} + : { connected: state.status === 'connected', connectionStatus: state.status }), + ...(state?.remotePlatform === undefined ? {} : { remotePlatform: state.remotePlatform }) + } + } catch { + return target + } + }) + ) +} + // Why: `--host ssh:` was never validated, so an unknown target answered ok:true with an // empty list — the same silent wrong-machine answer that `runtime:` ids used to give. And since // target ids are machine-generated (`ssh--`), the label a caller actually diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts index b8db44915d2..1a195cc52ff 100644 --- a/src/cli/index-local-command-routing-flags.test.ts +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -48,7 +48,7 @@ import { main } from './index' import { okFixture, queueFixtures } from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' -const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw' } +const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw', remotePlatform: 'win32' } /** Every SSH-target lookup answers with the one target only this machine's runtime knows about. */ function queueSshTargetLookups(count: number): void { @@ -82,6 +82,9 @@ describe('runtime-selector flags on locally pinned CLI commands', () => { SSH_TARGET.id, 'env-m4air' ]) + expect( + printed.result.hosts.find((host: { id: string }) => host.id === SSH_TARGET.id).platform + ).toBe('win32') // The tell: `runtimeId: local` is only honest if no routed client was ever built. expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index 9389113b195..a0e1354307f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ import { resolveHostFlagEnvironmentId } from './execution-host-flag' import { listSshTargets } from './host-selector-alternatives' -import { reportCliError } from './format' +import { reportCliError } from './cli-error' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' diff --git a/src/cli/orchestration-dispatch-refusal-format.test.ts b/src/cli/orchestration-dispatch-refusal-format.test.ts new file mode 100644 index 00000000000..0a4461f25f1 --- /dev/null +++ b/src/cli/orchestration-dispatch-refusal-format.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + injectRejectedRefusal, + taskNotFoundRefusal, + taskNotStartableRefusal, + type DispatchRefusalReceipt +} from '../shared/orchestration-dispatch-refusal-contract' +import { formatCliError, reportCliError } from './format' +import { RuntimeRpcFailureError, type RuntimeRpcFailure } from './runtime/types' + +afterEach(() => { + vi.restoreAllMocks() +}) + +// Why: these are the exact envelopes the RPC dispatcher test proved the runtime emits. This +// checkout's formatter never enumerates codes (verified below with a code no build has defined), +// which is what lets a client that predates a new code still print its message and nextSteps. +describe('orchestration dispatch refusals through the CLI error boundary', () => { + it.each([ + { + receipt: taskNotFoundRefusal('Task not found: task_missing', { taskId: 'task_missing' }), + recovery: /task-create|task-list/ + }, + { + receipt: taskNotStartableRefusal( + 'Task task_child is pending; only ready tasks can be dispatched', + { taskId: 'task_child', status: 'pending', unmetDependencies: ['task_parent'] } + ), + recovery: /task_parent/ + }, + { + receipt: injectRejectedRefusal('term_worker', 'no_agent_detected'), + recovery: /without --inject/ + } + ])('prints $receipt.code with its recovery in human and JSON output', ({ receipt, recovery }) => { + const failure = envelope(receipt) + const error = new RuntimeRpcFailureError(failure) + expect(error.code).toBe(receipt.code) + + const human = formatCliError(error, { commandPath: ['orchestration', 'dispatch'] }) + expect(human).toContain(receipt.message) + expect(human).toMatch(recovery) + + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + reportCliError(error, true, { commandPath: ['orchestration', 'dispatch'] }) + const printed = JSON.parse(log.mock.calls[0]?.[0] as string) as RuntimeRpcFailure + expect(printed.ok).toBe(false) + expect(printed.error).toEqual(receipt) + }) +}) + +// Why: a code this build has never defined stands in for a future host's new code; if the +// formatter ever starts gating on known codes, this is the assertion that catches it. +it('prints an unknown code with its message and nextSteps unchanged', () => { + const failure: RuntimeRpcFailure = { + id: 'rpc_1', + ok: false, + error: { + code: 'code_from_a_newer_host', + message: 'Refused for a reason this CLI has never heard of.', + data: { nextSteps: ['Do the thing the newer host suggested.'] } + }, + _meta: { runtimeId: 'runtime_1' } + } + const error = new RuntimeRpcFailureError(failure) + + expect(formatCliError(error, { commandPath: ['orchestration', 'dispatch'] })).toBe( + 'Refused for a reason this CLI has never heard of.\nNext step: Do the thing the newer host suggested.' + ) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + reportCliError(error, true, { commandPath: ['orchestration', 'dispatch'] }) + expect(JSON.parse(log.mock.calls[0]?.[0] as string)).toEqual(failure) +}) + +function envelope(receipt: DispatchRefusalReceipt): RuntimeRpcFailure { + return { id: 'rpc_1', ok: false, error: receipt, _meta: { runtimeId: 'runtime_1' } } +} diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index 658cc60f0a4..5fcdf8b9686 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -84,14 +84,12 @@ describe('RuntimeClient module-graph deferral', () => { process.exitCode = 0 }) - // Why: the whole point of the change. These six modules load on EVERY - // invocation, so a value-import of the barrel from any of them drags the - // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path. + // These eager modules must not pull the RuntimeClient dependency graph into help. it.each([ 'args.ts', 'flags.ts', 'dispatch.ts', - 'format.ts', + 'cli-error.ts', 'selectors.ts', 'execution-host-flag.ts' ])('%s imports error classes from ./runtime/types, not the barrel', (file) => { @@ -110,6 +108,7 @@ describe('RuntimeClient module-graph deferral', () => { expect(source).toContain("import type { RuntimeClient } from './runtime-client'") expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m) expect(source).toContain("await import('./runtime-client.js')") + expect(source).toContain("import { reportCliError } from './cli-error'") }) it('constructs no client for --help', async () => { diff --git a/src/cli/runtime/transport-framing.test.ts b/src/cli/runtime/transport-framing.test.ts new file mode 100644 index 00000000000..cf3150f60c9 --- /dev/null +++ b/src/cli/runtime/transport-framing.test.ts @@ -0,0 +1,150 @@ +import { EventEmitter } from 'node:events' +import { StringDecoder } from 'node:string_decoder' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { sendRequest } from './transport' + +const { createConnection } = vi.hoisted(() => ({ createConnection: vi.fn() })) +vi.mock('node:net', () => ({ createConnection })) +vi.mock('node:crypto', () => ({ randomUUID: () => 'request-1' })) + +const metadata: RuntimeMetadata = { + runtimeId: 'runtime-1', + pid: 123, + transports: [{ kind: 'unix', endpoint: 'test-only' }], + authToken: 'token', + startedAt: 1 +} +const reply = (result: unknown) => + `${JSON.stringify({ id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } })}\n` + +class TestSocket extends EventEmitter { + setEncoding = vi.fn() + write = vi.fn() + end = vi.fn() + destroy = vi.fn() +} +let socket: TestSocket + +beforeEach(() => { + socket = new TestSocket() + createConnection.mockReturnValue(socket) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('CLI runtime response framing', () => { + it.each([1, 7, 256, 4096])( + 'reads a fragmented response with %i-character chunks', + async (size) => { + const result = { data: '界😀'.repeat(10000) } + const encoded = reply(result) + const pending = sendRequest(metadata, 'terminal.read', {}, 30000) + for (let offset = 0; offset < encoded.length; offset += size) { + socket.emit('data', encoded.slice(offset, offset + size)) + } + await expect(pending).resolves.toMatchObject({ result }) + expect(socket.setEncoding).toHaveBeenCalledExactlyOnceWith('utf8') + expect(socket.end).toHaveBeenCalledOnce() + } + ) + + it('accepts Unicode split across socket bytes using the existing UTF-8 decoder', async () => { + const pending = sendRequest(metadata, 'terminal.read', {}, 30000) + const decoder = new StringDecoder('utf8') + for (const byte of Buffer.from(reply({ data: '界😀é' }))) { + socket.emit('data', decoder.write(Buffer.from([byte]))) + } + socket.emit('data', decoder.end()) + await expect(pending).resolves.toMatchObject({ result: { data: '界😀é' } }) + }) + + it('searches each fragment once without rescanning the accumulated reply', async () => { + const encoded = reply({ data: 'x'.repeat(1024 * 1024) }) + const pending = sendRequest(metadata, 'terminal.read', {}, 30000) + const originalIndexOf = String.prototype.indexOf + let searchedCharacters = 0 + const search = vi + .spyOn(String.prototype, 'indexOf') + .mockImplementation(function (this: string, value, position) { + if (value === '\n') { + searchedCharacters += this.length - (position ?? 0) + } + return originalIndexOf.call(this, value, position) + }) + try { + for (let offset = 0; offset < encoded.length; offset += 256) { + socket.emit('data', encoded.slice(offset, offset + 256)) + } + } finally { + search.mockRestore() + } + await expect(pending).resolves.toMatchObject({ ok: true }) + expect(searchedCharacters).toBe(encoded.length) + }) + + it('refreshes keepalives across chunks and ignores blanks and data after the final frame', async () => { + vi.useFakeTimers() + const pending = sendRequest(metadata, 'terminal.read', {}, 100) + await vi.advanceTimersByTimeAsync(90) + socket.emit('data', ' \r\n{"_keep') + socket.emit('data', 'alive":true}\n\t\n') + await vi.advanceTimersByTimeAsync(90) + socket.emit('data', `${reply({ data: 'done' })}invalid JSON\n`) + socket.emit('data', 'more ignored data') + await expect(pending).resolves.toMatchObject({ result: { data: 'done' } }) + expect(socket.end).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + ['broken JSON\n', 'invalid_runtime_response'], + ['{}\n', 'invalid_runtime_response'], + ['{"id":"other","ok":true,"result":{}}\n', 'invalid_runtime_response'], + [ + '{"id":"request-1","ok":true,"result":{},"_meta":{"runtimeId":"other"}}\n', + 'runtime_unavailable' + ] + ])( + 'rejects a fragmented invalid first frame before subsequent valid frames', + async (line, code) => { + const pending = sendRequest(metadata, 'terminal.read', {}, 30000) + socket.emit('data', line.slice(0, 2)) + socket.emit('data', line.slice(2) + reply({ data: 'ignored' })) + await expect(pending).rejects.toMatchObject({ code }) + expect(socket.end).toHaveBeenCalledOnce() + } + ) + + it('preserves terminal failure envelopes', async () => { + const pending = sendRequest(metadata, 'terminal.read', {}, 30000) + socket.emit('data', '{"id":"request-1","ok":false,"error":{"code":"bad","message":"no"}}\n') + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { code: 'bad', message: 'no' } + }) + }) + + it('rejects close with an incomplete frame and does not parse later data', async () => { + const pending = sendRequest(metadata, 'terminal.read', {}, 30000) + socket.emit('data', '{"id":') + socket.emit('close') + socket.emit('data', reply({ data: 'ignored' })) + await expect(pending).rejects.toMatchObject({ code: 'runtime_unavailable' }) + expect(socket.end).toHaveBeenCalledOnce() + }) + + it('destroys a timed out socket holding an incomplete frame', async () => { + vi.useFakeTimers() + const pending = sendRequest(metadata, 'terminal.read', {}, 100) + const rejected = expect(pending).rejects.toMatchObject({ code: 'runtime_timeout' }) + socket.emit('data', '{"id":') + await vi.advanceTimersByTimeAsync(100) + socket.emit('data', reply({ data: 'ignored' })) + await rejected + expect(socket.destroy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 091ca9da0e0..4e0d0a15ec7 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -31,7 +31,7 @@ export async function sendRequest( return } const socket = createConnection(transport.endpoint) - let buffer = '' + let lineSegments: string[] = [] let settled = false const requestId = randomUUID() @@ -40,6 +40,7 @@ export async function sendRequest( return } settled = true + lineSegments = [] socket.destroy() reject( new RuntimeClientError( @@ -56,6 +57,7 @@ export async function sendRequest( return } settled = true + lineSegments = [] clearTimeout(timeout) socket.end() if (result.ok === false) { @@ -89,18 +91,27 @@ export async function sendRequest( }) }) socket.on('data', (chunk: string) => { - buffer += chunk // Why: the server may interleave `{"_keepalive":true}\n` frames with the // final success/failure frame to keep both idle timers alive during a // long-poll (see design doc §3.1). Read frames in a loop until we see a // terminal frame. Each keepalive refreshes the client-side timer so a // 10 min wait doesn't trip the 60 s default ceiling. - let newlineIndex = buffer.indexOf('\n') - while (newlineIndex !== -1 && !settled) { - const line = buffer.slice(0, newlineIndex) - buffer = buffer.slice(newlineIndex + 1) + let cursor = 0 + while (cursor < chunk.length && !settled) { + const newlineIndex = chunk.indexOf('\n', cursor) + if (newlineIndex === -1) { + lineSegments.push(chunk.slice(cursor)) + return + } + const segment = chunk.slice(cursor, newlineIndex) + let line = segment + if (lineSegments.length > 0) { + lineSegments.push(segment) + line = lineSegments.join('') + lineSegments = [] + } + cursor = newlineIndex + 1 if (line.trim().length === 0) { - newlineIndex = buffer.indexOf('\n') continue } @@ -124,7 +135,6 @@ export async function sendRequest( // major). See §7 risk #9. if (isKeepaliveFrame(raw)) { timeout.refresh() - newlineIndex = buffer.indexOf('\n') continue } @@ -150,7 +160,6 @@ export async function sendRequest( const frame = parsed.data if ('_keepalive' in frame) { timeout.refresh() - newlineIndex = buffer.indexOf('\n') continue } diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 7bf90615270..64efbe802b9 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -10,6 +10,8 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.', 'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment . An SSH target is a machine the connected Orca host reaches, selected with --host ssh:. Passing one where the other belongs is the most common way to get an empty or missing-host answer.', + 'SSH rows include the detected remote platform after that target has connected (linux, darwin, or win32); disconnected or older targets report platform unknown.', + 'SSH rows also include whether the target is currently connected and its lifecycle status when known.', "SSH targets are read from this machine's own Orca runtime, so this lists that machine's targets and not another server's. Run `orca host list` on the other machine to see the targets registered there.", '--environment and --pairing-code are rejected rather than ignored: paired servers come from this machine\u2019s pairing store, so a routed answer would describe two machines at once.' ], diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 40237141d24..af70f6f54f0 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -4,7 +4,7 @@ // missing-Orca-env path, so their writer may break there. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { SFTPWrapper } from 'ssh2' @@ -60,6 +60,7 @@ import { DroidHookService } from '../droid/hook-service' import { GeminiHookService } from '../gemini/hook-service' import { GrokHookService } from '../grok/hook-service' import { KimiHookService } from '../kimi/hook-service' + import { openClaudeHookService } from '../openclaude/hook-service' import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils' import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract' @@ -69,6 +70,16 @@ import { findGitBash } from './windows-git-bash-path.test-fixture' const REMOTE_HOME = '/home/dev' const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x') + +// Why: a developer box may set HKCU\...\Command Processor\AutoRun, which cmd.exe runs before any +// .cmd — and MSYS spawns a .cmd without `/d`, so it fires on the Git Bash legs. Redirecting the +// profile makes the usual `%USERPROFILE%\.cmd_aliases.cmd` target vanish, putting cmd's "not +// recognized" on the hook's stderr. Seed an empty target so these suites measure the launcher +// rather than the host's shell configuration. +function seedCmdAutoRunTarget(profileDir: string): void { + mkdirSync(profileDir, { recursive: true }) + writeFileSync(join(profileDir, '.cmd_aliases.cmd'), '@echo off\r\n', 'utf8') +} const REMOTE_INSTALLERS = [ { agent: 'antigravity', @@ -228,6 +239,7 @@ describe('Windows managed hook stdin structure', () => { it('exits immediately when Orca env is missing and keeps drain for other failures', async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-')) homedirMock.mockReturnValue(home) + seedCmdAutoRunTarget(home) const previousGrokHome = process.env.GROK_HOME const previousKimiHome = process.env.KIMI_CODE_HOME delete process.env.GROK_HOME @@ -317,6 +329,7 @@ describe('Windows managed hook stdin structure', () => { async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-live-')) homedirMock.mockReturnValue(home) + seedCmdAutoRunTarget(home) try { const gitBash = findGitBash() for (const entry of LOCAL_INSTALLERS) { @@ -399,6 +412,9 @@ describe('Windows managed hook stdin structure', () => { async () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdout-json-')) homedirMock.mockReturnValue(home) + const absentProfile = join(home, 'absent') + seedCmdAutoRunTarget(home) + seedCmdAutoRunTarget(absentProfile) try { expect(new ClaudeHookService().install().state).toBe('installed') const settings = JSON.parse( @@ -426,8 +442,12 @@ describe('Windows managed hook stdin structure', () => { }) }, { + // Why: the encoded launcher resolves %USERPROFILE% at run time, so redirecting it is + // what makes the script vanish for that shape. The direct launcher (#18875) carries + // an absolute path, so here it asserts only that a bogus profile changes nothing; its + // missing-script fallback is covered live in windows-direct-cmd-hook-command.test.ts. name: 'missing managed script', - env: hookEnvironment({ USERPROFILE: join(home, 'absent') }) + env: hookEnvironment({ USERPROFILE: absentProfile }) } ] for (const shell of shells) { diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts new file mode 100644 index 00000000000..acb4bf2d46d --- /dev/null +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts @@ -0,0 +1,143 @@ +// Why (#18875): the registered Windows Claude hook is now the script path itself, so this file +// pins the two things that make that safe — the shape carries nothing MSYS or cmd.exe rewrites, +// and it still answers with neutral JSON when the script is gone. The live legs run the string +// through BOTH hosts Claude Code can pick, because the shape has to parse in either. +import { describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' +import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-command' +import { findGitBash } from './windows-git-bash-path.test-fixture' + +const SAFE_PATH = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + +describe('wrapWindowsDirectCmdHookCommand', () => { + it('emits the script path with forward slashes and a neutral-JSON fallback', () => { + expect(wrapWindowsDirectCmdHookCommand(SAFE_PATH)).toBe( + 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}' + ) + }) + + it('spells nothing either shell would rewrite or reinterpret', () => { + const command = wrapWindowsDirectCmdHookCommand(SAFE_PATH)! + + // Why: MSYS rewrites `/c`-shaped tokens into drive paths — a literal `cmd.exe /d /c ` + // does not survive Git Bash (measured), which is why no interpreter is spelled at all. + expect(command).not.toMatch(/ \/[a-zA-Z]+( |$)/) + expect(command).not.toMatch(/\\/) + expect(command).not.toMatch(/["']/) + expect(command).not.toMatch(/powershell|cmd\.exe|conhost/i) + // Why: `2>nul` writes a literal file named `nul` into the cwd under MSYS (measured), and no + // stderr sink parses in both hosts. The missing-script line is left on stderr deliberately. + expect(command).not.toContain('2>') + }) + + it('declines any path the shells cannot carry bare', () => { + for (const path of [ + 'C:\\Users\\Bob Smith\\.orca\\agent-hooks\\claude-hook.cmd', + 'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd', + 'C:\\Users\\a^b\\.orca\\agent-hooks\\claude-hook.cmd', + 'C:\\Users\\a&b\\.orca\\agent-hooks\\claude-hook.cmd', + 'C:\\Users\\a(b)\\.orca\\agent-hooks\\claude-hook.cmd', + 'C:\\Users\\rené\\.orca\\agent-hooks\\claude-hook.cmd', + '/home/alice/.orca/agent-hooks/claude-hook.sh', + // Why: WINDOWS_CMD_SAFE_PATH admits a UNC profile, but `//server/share/...` is not a + // command cmd.exe reliably starts — keep those on the encoded launcher. + '\\\\server\\share\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + ]) { + expect(wrapWindowsDirectCmdHookCommand(path), path).toBeNull() + } + }) +}) + +describe.skipIf(process.platform !== 'win32')('direct hook command, run by both hook hosts', () => { + // Why: the fixture throws when Git Bash is absent, and that is a skip here, not a failure — + // a box without it never gets this command shape in the first place. + const gitBash = ((): string | null => { + try { + return findGitBash() + } catch { + return null + } + })() + + function runInCmd(command: string, cwd: string): { stdout: string; status: number } { + return runCapture('cmd.exe', ['/d', '/c', command], cwd) + } + + function runInBash(command: string, cwd: string): { stdout: string; status: number } { + return runCapture(gitBash!, ['-c', command], cwd) + } + + function runCapture(file: string, args: string[], cwd: string) { + try { + const stdout = execFileSync(file, args, { + cwd, + input: '{"hook_event_name":"PreToolUse"}', + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }) + return { stdout, status: 0 } + } catch (error) { + const failure = error as { stdout?: string; status?: number } + return { stdout: failure.stdout ?? '', status: failure.status ?? 1 } + } + } + + // Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case, + // so these legs skip rather than assert a contract that shape never claimed. + const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd')) + const canRunLive = Boolean(gitBash) && tempIsCmdSafe + + function withTempDir(run: (dir: string, scriptPath: string, command: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-')) + try { + const scriptPath = join(dir, 'claude-hook.cmd') + const command = wrapWindowsDirectCmdHookCommand(scriptPath) + expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull() + run(dir, scriptPath, command!) + } finally { + // Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on + // Windows while their handles drain. + removeTreeSync(dir) + } + } + + it.skipIf(!canRunLive)('answers {} and exit 0 in both hosts when the script exists', () => { + withTempDir((dir, scriptPath, command) => { + writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8') + for (const result of [runInCmd(command, dir), runInBash(command, dir)]) { + expect(result.stdout.trim()).toBe('{}') + expect(result.status).toBe(0) + } + }) + }) + + it.skipIf(!canRunLive)( + 'still answers {} and exit 0 in both hosts when the script is gone', + () => { + // Why: compat consumers require neutral JSON even with no managed script (#14818). The + // encoded launcher did this with a Test-Path; `|| echo {}` does it with no interpreter. + withTempDir((dir, scriptPath, command) => { + expect(existsSync(scriptPath)).toBe(false) + for (const result of [runInCmd(command, dir), runInBash(command, dir)]) { + expect(result.stdout.trim()).toBe('{}') + expect(result.status).toBe(0) + } + }) + } + ) + + it.skipIf(!canRunLive)('leaves no stray `nul` file behind in the working directory', () => { + // Why this is worth a test: adding `2>nul` to silence the missing-script line looks like + // tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the + // user's repo. Measured on Windows 11. Keep stderr unredirected. + withTempDir((dir, _scriptPath, command) => { + runInBash(command, dir) + expect(readdirSync(dir)).not.toContain('nul') + }) + }) +}) diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts new file mode 100644 index 00000000000..f7646f19578 --- /dev/null +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts @@ -0,0 +1,30 @@ +import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' + +// Why: a drive-letter path only. WINDOWS_CMD_SAFE_PATH also admits a UNC profile, and +// `//server/share/...` is not a command cmd.exe reliably starts. +const WINDOWS_DRIVE_LETTER_PATH = /^[A-Za-z]:\\/ + +/** + * Shortest launcher for a managed Windows `.cmd` hook: the script path itself (#18875). + * + * The encoded PowerShell launcher spent a full interpreter start-up per hook event to reach a + * script that exits at its first `ORCA_PANE_KEY` guard, and left a stdout-holding orphan behind + * when the hook's timeout kill landed. Measurements and the EDR trade are in + * `docs/reference/windows-edr-posture.md`. + * + * Returns null when the caller must keep the encoded launcher: a path either shell would mangle. + */ +export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | null { + if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath) || !WINDOWS_DRIVE_LETTER_PATH.test(scriptPath)) { + return null + } + // Why: forward slashes are the one separator both hosts read, and no token here is a switch + // MSYS can rewrite — a literal `cmd.exe /d /c ` does not survive Git Bash (measured). + const invocation = scriptPath.replaceAll('\\', '/') + // Why: neutral JSON when the script is missing (#14818), with no interpreter to Test-Path with. + // Valid in bash and cmd.exe; PowerShell 5.1 rejects `||`, which is what gates this on Git Bash. + // It also fires when cmd.exe itself exits non-zero (a failing AutoRun), printing `{}` twice — + // on that same box the encoded launcher exited 1 instead, so neither shape is clean there. + // Stderr stays unredirected: `2>nul` writes a literal `nul` file into the cwd under MSYS. + return `${invocation} || echo {}` +} diff --git a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts index 22103b178ff..79d9f4f60ae 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' import { createServer, type Server } from 'node:http' -import { mkdtempSync, readFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,6 +32,7 @@ vi.mock('os', async (importOriginal) => { }) import { ClaudeHookService } from '../claude/hook-service' +import { WINDOWS_CMD_SAFE_PATH } from './installer-utils' import { getConfigPath, getWindowsManagedLifecycleHook } from '../claude/hook-settings' import { findGitBash } from './windows-git-bash-path.test-fixture' @@ -122,6 +123,15 @@ function runHookCommand( }) } +// Why: a developer box may set HKCU\...\Command Processor\AutoRun, which cmd.exe runs before +// any .cmd — and MSYS spawns a .cmd without `/d`, so it fires on the Git Bash leg. Redirecting +// USERPROFILE to a temp home makes the usual `%USERPROFILE%\.cmd_aliases.cmd` target vanish, and +// cmd's "not recognized" lands on the hook's stderr. Seed an empty target so this suite measures +// the launcher rather than the host's shell configuration. +function seedCmdAutoRunTarget(home: string): void { + writeFileSync(join(home, '.cmd_aliases.cmd'), '@echo off\r\n', 'utf8') +} + function hookEnvironment(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const base = Object.fromEntries( Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_')) @@ -158,6 +168,7 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli it('delivers the piped payload to the hook listener through cmd.exe and Git Bash', async () => { home = mkdtempSync(join(tmpdir(), 'orca-hook-payload-')) homedirMock.mockReturnValue(home) + seedCmdAutoRunTarget(home) expect(new ClaudeHookService().install().state).toBe('installed') const settings = JSON.parse(readFileSync(getConfigPath(), 'utf8')) as { @@ -166,6 +177,11 @@ describe.skipIf(process.platform !== 'win32')('Windows managed hook payload deli // Why: assert nothing about the launcher's shape here — this test's whole value is // that it fails for any launcher that loses the payload, named conhost or not. const registeredCommand = settings.hooks.PreToolUse[0].hooks[0].command + // ...with one exception: a cmd-safe profile must reach the script with no interpreter in + // front of it, or #18875's per-event PowerShell start-up has quietly come back. + if (WINDOWS_CMD_SAFE_PATH.test(join(home, '.orca', 'agent-hooks', 'claude-hook.cmd'))) { + expect(registeredCommand).not.toMatch(/powershell|-EncodedCommand/i) + } const listener = await startHookListener() server = listener.server diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index b9a9f6dd208..2cdb8c0f3fa 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -39,6 +39,11 @@ export function getWindowsPowerShellExecutablePath(): string { * Do not restore the flag to fix a console report. That trades every hook on an * AV host for a flicker. The answer is to shorten the interpreter chain — the * shipped doctrine of #15520 and #15595 — or a launcher that owns no console. + * + * #18875 took that answer for the Claude lifecycle hook, which now registers the + * managed `.cmd` path directly (`windows-direct-cmd-hook-command.ts`) and reaches + * this launcher only when the profile path is not cmd-safe or Git Bash is not + * resolvable. Every other caller still comes through here on every event. */ export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile' diff --git a/src/main/artifacts/artifact-cloud-recovery.test.ts b/src/main/artifacts/artifact-cloud-recovery.test.ts index f57b53c2b02..fea3b73bda0 100644 --- a/src/main/artifacts/artifact-cloud-recovery.test.ts +++ b/src/main/artifacts/artifact-cloud-recovery.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { isPackaged: false }, @@ -21,7 +21,14 @@ const writeRequest = { authToken: 'token-a' } +beforeEach(() => { + // Keep fixed response expirations independent of the runner's wall clock. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime('2026-08-07T00:00:00.000Z') +}) + afterEach(async () => { + vi.useRealTimers() vi.unstubAllGlobals() await Promise.all( createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) @@ -336,7 +343,7 @@ function createResponseBody(slug: string): object { renderedContentType: 'text/html', createdAt: '2026-08-06T00:00:00.000Z', updatedAt: '2026-08-06T00:00:00.000Z', - expiresAt: '2026-09-06T00:00:00.000Z', + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), byteSize: 17, deletedAt: null }, diff --git a/src/main/artifacts/artifact-cloud-service-races.test.ts b/src/main/artifacts/artifact-cloud-service-races.test.ts index c31c2a23e3e..28195bf6965 100644 --- a/src/main/artifacts/artifact-cloud-service-races.test.ts +++ b/src/main/artifacts/artifact-cloud-service-races.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { isPackaged: false }, @@ -33,7 +33,7 @@ function createResponse(slug: string): Response { renderedContentType: 'text/html', createdAt: '2026-08-06T00:00:00.000Z', updatedAt: '2026-08-06T00:00:00.000Z', - expiresAt: '2026-09-06T00:00:00.000Z', + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), byteSize: 12, deletedAt: null }, @@ -50,7 +50,14 @@ async function setup(): Promise { return new ArtifactCloudService(path, () => true) } +beforeEach(() => { + // Keep fixed response expirations independent of the runner's wall clock. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime('2026-08-07T00:00:00.000Z') +}) + afterEach(async () => { + vi.useRealTimers() vi.unstubAllGlobals() await Promise.all( createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) diff --git a/src/main/artifacts/artifact-cloud-service.test.ts b/src/main/artifacts/artifact-cloud-service.test.ts index 75da3922fc8..c747e6517aa 100644 --- a/src/main/artifacts/artifact-cloud-service.test.ts +++ b/src/main/artifacts/artifact-cloud-service.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ app: { isPackaged: false }, @@ -43,7 +43,10 @@ const cloudB: OrcaProfileCloudSummary = { linkedAt: 2 } -function createResponse(slug = 'artifact-a', expiresAt = '2026-09-06T00:00:00.000Z'): Response { +function createResponse( + slug = 'artifact-a', + expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() +): Response { return new Response( JSON.stringify({ artifact: { @@ -92,6 +95,12 @@ const writeRequest = { authToken: 'token-a' } +beforeEach(() => { + // Keep fixed response expirations independent of the runner's wall clock. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime('2026-08-07T00:00:00.000Z') +}) + afterEach(async () => { vi.useRealTimers() vi.unstubAllGlobals() diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts index f512f5ab439..7c07520576e 100644 --- a/src/main/artifacts/artifact-create-intent-store.test.ts +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -1,4 +1,3 @@ -import { execFileSync } from 'node:child_process' import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,9 +15,14 @@ import { getOrCreateArtifactCreateIntent, removeArtifactCreateIntent } from './artifact-create-intent-store' +import { runProcessSync } from '../../shared/child-process/run-process' +import { __resetSecureFileWindowsUserSidForTests } from '../../shared/secure-file' import type { ArtifactShareScope } from './artifact-share-record-store' -vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() })) +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: vi.fn(), + runProcessSync: vi.fn() +})) const createdPaths: string[] = [] const scope: ArtifactShareScope = { @@ -168,12 +172,26 @@ describe('artifact create intent store', () => { expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false) }) - it('hardens one Windows journal directory without per-file PowerShell launches', async () => { + it('hardens one Windows journal directory without per-file ACL launches', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.mocked(execFileSync).mockImplementation((file) => - String(file).endsWith('whoami.exe') ? '"USER","S-1-5-21-1000"' : '' - ) + const ok = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } + // Earlier cases in this file already resolved (and cached) the SID against an unstubbed mock. + __resetSecureFileWindowsUserSidForTests() + vi.mocked(runProcessSync).mockImplementation((spec) => { + if (spec.program.endsWith('whoami.exe')) { + return { ...ok, stdout: '"USER","S-1-5-21-1000"' } + } + const args = spec.args ?? [] + if (args.length > 1) { + return ok // /reset and the /grant:r pass + } + // The verify pass re-reads the DACL; answer with the three protected inheritable rules. + const rules = ['host\\me', 'NT AUTHORITY\\SYSTEM', 'BUILTIN\\Administrators'].map( + (name, index) => (index === 0 ? `${args[0]} ${name}:(OI)(CI)(F)` : ` ${name}:(OI)(CI)(F)`) + ) + return { ...ok, stdout: `${rules.join('\r\n')}\r\n\r\nSuccessfully processed 1 files\r\n` } + }) try { const userDataPath = await createUserDataPath() getOrCreateArtifactCreateIntent( @@ -193,16 +211,20 @@ describe('artifact create intent store', () => { body ) - const powershellCalls = vi - .mocked(execFileSync) - .mock.calls.filter(([file]) => String(file).endsWith('powershell.exe')) - expect(powershellCalls).toHaveLength(1) - expect((powershellCalls[0]![1] as string[]).at(-1)).toBe('1') + // One harden across both intents: counted by its /reset pass, which opens each harden. + const aclCalls = vi + .mocked(runProcessSync) + .mock.calls.map(([spec]) => spec) + .filter((spec) => spec.program.endsWith('icacls.exe')) + expect(aclCalls.filter((spec) => spec.args?.includes('/reset'))).toHaveLength(1) + // The child intent files rely on inheritance, so the directory rules must carry (OI)(CI). + const grant = aclCalls.find((spec) => spec.args?.includes('/grant:r')) + expect(grant?.args?.filter((arg) => arg.endsWith(':(OI)(CI)(F)'))).toHaveLength(3) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } - vi.mocked(execFileSync).mockReset() + vi.mocked(runProcessSync).mockReset() } }) diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts index 24c26471c3a..315d6ae1779 100644 --- a/src/main/browser/browser-clicked-link-routing.test.ts +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -10,6 +10,7 @@ import { } from './browser-clicked-link-routing' const FOREGROUND_FRAME_NAME = '__orca_clicked_link_foreground_test' +const BACKGROUND_FRAME_NAME = '__orca_clicked_link_background_test' type RoutingGlobal = typeof globalThis & { __orcaBrowserClickedLinkRouting?: { @@ -32,7 +33,7 @@ function resetRouting(): void { } function installRouting(isMac = true): void { - installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, isMac, true) + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, BACKGROUND_FRAME_NAME, isMac, true) } function clickLink( @@ -90,7 +91,7 @@ describe('browser clicked-link routing', () => { expect(clickLink(link, { metaKey: true }).open).not.toHaveBeenCalled() expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( 'https://example.com/reference', - FOREGROUND_FRAME_NAME + BACKGROUND_FRAME_NAME ) }) @@ -102,7 +103,7 @@ describe('browser clicked-link routing', () => { expect(clickLink(link, { type: 'auxclick' }).open).toHaveBeenCalledWith( 'https://example.com/reference', - FOREGROUND_FRAME_NAME + BACKGROUND_FRAME_NAME ) expect(clickLink(link, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( 'https://example.com/reference', @@ -110,6 +111,28 @@ describe('browser clicked-link routing', () => { ) }) + it.each([true, false])('routes Shift+middle-click to the foreground (isMac=%s)', (isMac) => { + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting(isMac) + + const { event, open } = clickLink(link, { type: 'auxclick', shiftKey: true }) + expect(event.defaultPrevented).toBe(true) + expect(open).toHaveBeenCalledWith(link.href, FOREGROUND_FRAME_NAME) + + resetRouting() + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + isMac, + true + ) + const iframeClick = clickLink(link, { type: 'auxclick', shiftKey: true }) + expect(iframeClick.event.defaultPrevented).toBe(true) + expect(iframeClick.open).toHaveBeenCalledWith(link.href, FOREGROUND_FRAME_NAME) + }) + it('honors page cancellation and observes link rewrites before routing', () => { const cancelled = document.createElement('a') cancelled.href = 'https://example.com/original' @@ -180,21 +203,35 @@ describe('browser clicked-link routing', () => { const link = document.createElement('a') link.href = 'https://example.com/reference' document.body.append(link) - installBrowserClickedLinkRouting('__orca_clicked_link_old_fg', true, true) - installBrowserClickedLinkRouting('__orca_clicked_link_new_fg', false, true) + installBrowserClickedLinkRouting( + '__orca_clicked_link_old_fg', + '__orca_clicked_link_old_bg', + true, + true + ) + installBrowserClickedLinkRouting( + '__orca_clicked_link_new_fg', + '__orca_clicked_link_new_bg', + false, + true + ) expect(addEventListener.mock.calls.filter(([event]) => event === 'click')).toHaveLength(1) expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( 'https://example.com/reference', - '__orca_clicked_link_new_fg' + '__orca_clicked_link_new_bg' ) }) it('builds a self-contained isolated-world script', () => { - const script = buildBrowserClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + const script = buildBrowserClickedLinkRoutingScript( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + false + ) expect(script).toContain('installBrowserClickedLinkRouting') - expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}","${BACKGROUND_FRAME_NAME}",false`) expect(script).not.toContain('BrowserClickedLinkRoutingState') }) @@ -203,7 +240,12 @@ describe('browser clicked-link routing', () => { link.href = 'https://example.com/from-frame' link.target = '_blank' document.body.append(link) - cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + true, + true + ) const { event, open } = clickLink(link) @@ -216,20 +258,35 @@ describe('browser clicked-link routing', () => { blank.href = 'https://example.com/new-context' blank.target = '_blank' document.body.append(blank) - cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + true, + true + ) expect(clickLink(blank, { metaKey: true }).open).toHaveBeenCalledWith( 'https://example.com/new-context', - FOREGROUND_FRAME_NAME + BACKGROUND_FRAME_NAME ) - cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + true, + true + ) expect(clickLink(blank, { type: 'auxclick' }).open).toHaveBeenCalledWith( 'https://example.com/new-context', - FOREGROUND_FRAME_NAME + BACKGROUND_FRAME_NAME ) - cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + true, + true + ) expect(clickLink(blank, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( 'https://example.com/new-context', FOREGROUND_FRAME_NAME @@ -243,7 +300,12 @@ describe('browser clicked-link routing', () => { const ordinary = document.createElement('a') ordinary.href = 'https://example.com/current' document.body.append(blank, ordinary) - cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + true, + true + ) expect(clickLink(blank, { ctrlKey: true }).open).not.toHaveBeenCalled() expect(clickLink(blank, { shiftKey: true }).open).not.toHaveBeenCalled() @@ -257,8 +319,12 @@ describe('browser clicked-link routing', () => { frameLink.href = 'https://example.com/frame-synthetic' frameLink.target = '_blank' document.body.append(mainLink, frameLink) - installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, true) - cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, BACKGROUND_FRAME_NAME, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + true + ) expect(clickLink(mainLink, { metaKey: true }).open).not.toHaveBeenCalled() expect(clickLink(frameLink).open).not.toHaveBeenCalled() @@ -266,9 +332,13 @@ describe('browser clicked-link routing', () => { }) it('builds a self-contained iframe script with per-frame routing tokens', () => { - const script = buildBrowserIframeClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + const script = buildBrowserIframeClickedLinkRoutingScript( + FOREGROUND_FRAME_NAME, + BACKGROUND_FRAME_NAME, + false + ) expect(script).toContain('installBrowserIframeClickedLinkRouting') - expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}","${BACKGROUND_FRAME_NAME}",false`) }) }) diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts index ee6f2fc43d5..4189f222ef8 100644 --- a/src/main/browser/browser-clicked-link-routing.ts +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -1,7 +1,13 @@ export const BROWSER_CLICKED_LINK_ROUTING_WORLD_ID = 1208 +export type BrowserClickedLinkFrameNames = { + foreground: string + background: string +} + type BrowserClickedLinkRoutingState = { - frameName: string + foregroundFrameName: string + backgroundFrameName: string isMac: boolean allowUntrustedEvents: boolean listener: (event: MouseEvent) => void @@ -16,21 +22,24 @@ type BrowserClickedLinkRoutingGlobal = typeof globalThis & { * can distinguish them from opener-dependent window.open calls. */ export function installBrowserClickedLinkRouting( - frameName: string, + foregroundFrameName: string, + backgroundFrameName: string, isMac: boolean, allowUntrustedEvents = false ): void { const routingGlobal = globalThis as BrowserClickedLinkRoutingGlobal const existing = routingGlobal.__orcaBrowserClickedLinkRouting if (existing) { - existing.frameName = frameName + existing.foregroundFrameName = foregroundFrameName + existing.backgroundFrameName = backgroundFrameName existing.isMac = isMac existing.allowUntrustedEvents = allowUntrustedEvents return } const state: BrowserClickedLinkRoutingState = { - frameName, + foregroundFrameName, + backgroundFrameName, isMac, allowUntrustedEvents, listener: () => {} @@ -68,7 +77,7 @@ export function installBrowserClickedLinkRouting( } // Shift alone is browser-native new-window intent; keep OAuth and other // opener-dependent window flows in Orca's guarded popup window. - if (event.shiftKey && !modifierClick) { + if (event.shiftKey && !modifierClick && !middleClick) { return } @@ -101,7 +110,11 @@ export function installBrowserClickedLinkRouting( // with the same disposition. The private frame name preserves that one // distinction without weakening OAuth popups that need window.opener. event.preventDefault() - window.open(targetUrl.toString(), state.frameName) + const openInBackground = (middleClick || modifierClick) && !event.shiftKey + window.open( + targetUrl.toString(), + openInBackground ? state.backgroundFrameName : state.foregroundFrameName + ) } routingGlobal.__orcaBrowserClickedLinkRouting = state @@ -116,7 +129,8 @@ export function installBrowserClickedLinkRouting( * API cannot reach, so this runs in the page world against a one-use token. */ export function installBrowserIframeClickedLinkRouting( - frameName: string, + foregroundFrameName: string, + backgroundFrameName: string, isMac: boolean, allowUntrustedEvents = false ): () => void { @@ -148,7 +162,7 @@ export function installBrowserIframeClickedLinkRouting( const modifierClick = isMac ? event.metaKey : event.ctrlKey const otherPlatformModifier = isMac ? event.ctrlKey : event.metaKey - if (otherPlatformModifier || (event.shiftKey && !modifierClick)) { + if (otherPlatformModifier || (event.shiftKey && !modifierClick && !middleClick)) { return } @@ -179,7 +193,8 @@ export function installBrowserIframeClickedLinkRouting( // A page that observes a real click cannot replay it to create more tabs. event.preventDefault() cleanup() - window.open(targetUrl.toString(), frameName) + const openInBackground = (middleClick || modifierClick) && !event.shiftKey + window.open(targetUrl.toString(), openInBackground ? backgroundFrameName : foregroundFrameName) } const cleanup = (): void => { @@ -191,13 +206,18 @@ export function installBrowserIframeClickedLinkRouting( return cleanup } -export function buildBrowserClickedLinkRoutingScript(frameName: string, isMac: boolean): string { - return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +export function buildBrowserClickedLinkRoutingScript( + foregroundFrameName: string, + backgroundFrameName: string, + isMac: boolean +): string { + return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(foregroundFrameName)},${JSON.stringify(backgroundFrameName)},${JSON.stringify(isMac)});` } export function buildBrowserIframeClickedLinkRoutingScript( - frameName: string, + foregroundFrameName: string, + backgroundFrameName: string, isMac: boolean ): string { - return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` + return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(foregroundFrameName)},${JSON.stringify(backgroundFrameName)},${JSON.stringify(isMac)});` } diff --git a/src/main/browser/browser-client-upload-transfer.test.ts b/src/main/browser/browser-client-upload-transfer.test.ts index fdc129618e8..84bb649abd3 100644 --- a/src/main/browser/browser-client-upload-transfer.test.ts +++ b/src/main/browser/browser-client-upload-transfer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { BrowserClientHostCommandEvent } from '../../shared/browser-client-host-protocol' import { @@ -102,3 +102,38 @@ describe('readBrowserClientUploadPaths', () => { ) }) }) + +it.each([0, 1, 128 * 1024])( + 'avoids recopying 16 single-chunk uploads of %i bytes', + async (size) => { + const source = Buffer.alloc(size, 171) + const response = { + contentBase64: source.toString('base64'), + bytesRead: size, + totalBytes: size, + eof: true + } + const remotePaths = Array.from({ length: 16 }, (_, i) => `file-${i}.bin`) + const request = vi.fn(async () => response) + const concat = vi.spyOn(Buffer, 'concat') + let copies = 0 + let files: Awaited> + try { + files = await fetchBrowserClientUploadFiles({ request, event, remotePaths }) + copies = concat.mock.calls.length + } finally { + concat.mockRestore() + } + expect(copies).toBe(0) + expect(request).toHaveBeenCalledTimes(16) + expect(files.map((file) => file.remotePath)).toEqual(remotePaths) + for (const file of files) { + expect(file.contents).toEqual(source) + } + if (size > 0) { + files[0].contents[0] = 0 + expect(files[1].contents[0]).toBe(171) + expect(source[0]).toBe(171) + } + } +) diff --git a/src/main/browser/browser-client-upload-transfer.ts b/src/main/browser/browser-client-upload-transfer.ts index 1863f7b9f75..f85af071633 100644 --- a/src/main/browser/browser-client-upload-transfer.ts +++ b/src/main/browser/browser-client-upload-transfer.ts @@ -74,7 +74,7 @@ export async function fetchBrowserClientUploadFiles(options: { throw new Error('browser_client_upload_transfer_stalled') } } - files.push({ remotePath, contents: Buffer.concat(chunks) }) + files.push({ remotePath, contents: chunks.length === 1 ? chunks[0] : Buffer.concat(chunks) }) } return files } diff --git a/src/main/browser/browser-manager-final.ts b/src/main/browser/browser-manager-final.ts index b19369fc91d..49a5110ce54 100644 --- a/src/main/browser/browser-manager-final.ts +++ b/src/main/browser/browser-manager-final.ts @@ -3,7 +3,7 @@ import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { BrowserManagerEventForwarding } from './browser-manager-event-forwarding' export abstract class BrowserManagerFinal extends BrowserManagerEventForwarding { - protected openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + protected openLinkInOrcaTab(browserTabId: string, rawUrl: string, activate?: boolean): boolean { const renderer = this.resolveRendererForBrowserTab(browserTabId) if (!renderer) { return false @@ -15,7 +15,8 @@ export abstract class BrowserManagerFinal extends BrowserManagerEventForwarding // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. renderer.send('browser:open-link-in-orca-tab', { browserPageId: browserTabId, - url: normalizedUrl + url: normalizedUrl, + ...(activate === false ? { activate: false } : {}) }) return true } diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts index 9dac4f3f665..a815e95e431 100644 --- a/src/main/browser/browser-manager-guest-cleanup.ts +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -20,7 +20,6 @@ export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavi this.policyCleanupByGuestId.delete(guestWebContentsId) } this.policyAttachedGuestIds.delete(guestWebContentsId) - this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index e136f532474..425d1fe59f6 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -288,19 +288,17 @@ describe('browserManager', () => { webContentsId: 102, rendererWebContentsId }) - // Why: attach-before-registration teardown skips unregisterGuest, so global - // cleanup must independently release the private click-routing token. - browserManager.attachGuestPolicies({ ...guest, id: 103 } as never) + // Guests attached before registration still need their policy listeners released. + const unregisteredGuestOff = vi.fn() + browserManager.attachGuestPolicies({ ...guest, id: 103, off: unregisteredGuestOff } as never) browserManager.unregisterAll() expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull() expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull() expect(guestOffMock).toHaveBeenCalled() - const managerState = browserManager as unknown as { - clickedLinkFrameNameByGuestId: Map - } - expect(managerState.clickedLinkFrameNameByGuestId.size).toBe(0) + expect(unregisteredGuestOff).toHaveBeenCalledWith('dom-ready', expect.any(Function)) + expect(unregisteredGuestOff).toHaveBeenCalledWith('frame-created', expect.any(Function)) }) it('rejects non-webview guest types to prevent privilege escalation', () => { diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts index 3952a31c08b..dd418d71af7 100644 --- a/src/main/browser/browser-manager-guest-policy.ts +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto' import { BROWSING_GUEST_POLICY, type BrowserGuestPolicy, @@ -27,18 +26,10 @@ export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestClean if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } - // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. - const clickedLinkFrameName = inheritedOwnerContext - ? null - : `__orca_clicked_link_foreground_${randomUUID()}` - if (clickedLinkFrameName) { - this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) - } - const disposeAuthDetachTracking = this.trackDebuggerDetachForAuthUserAgent(guest) // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) - const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName) + const disposePopupPolicy = this.installGuestPopupPolicy(guest, !inheritedOwnerContext) const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest) // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. diff --git a/src/main/browser/browser-manager-guest-popup-policy.ts b/src/main/browser/browser-manager-guest-popup-policy.ts index 43133fd8e29..70c3ef5abb6 100644 --- a/src/main/browser/browser-manager-guest-popup-policy.ts +++ b/src/main/browser/browser-manager-guest-popup-policy.ts @@ -9,7 +9,8 @@ import { import { BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, buildBrowserClickedLinkRoutingScript, - buildBrowserIframeClickedLinkRoutingScript + buildBrowserIframeClickedLinkRoutingScript, + type BrowserClickedLinkFrameNames } from './browser-clicked-link-routing' import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' import { SAFE_POPUP_WINDOW_OPTIONS, safeOrigin } from './browser-manager-types' @@ -19,11 +20,18 @@ import { BrowserManagerNavigation } from './browser-manager-navigation' export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavigation { protected installGuestPopupPolicy( guest: Electron.WebContents, - clickedLinkFrameName: string | null + routeClickedLinks: boolean ): () => void { - let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) + // OAuth child windows keep native link behavior. + const clickedLinkFrameNames: BrowserClickedLinkFrameNames | null = routeClickedLinks + ? { + foreground: `__orca_clicked_link_foreground_${randomUUID()}`, + background: `__orca_clicked_link_background_${randomUUID()}` + } + : null + let clickedLinkRoutingActive = routeClickedLinks const installClickedLinkRouting = (): void => { - if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + if (!clickedLinkRoutingActive || !clickedLinkFrameNames || guest.isDestroyed()) { return } // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. @@ -34,7 +42,8 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig { // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. code: buildBrowserClickedLinkRoutingScript( - clickedLinkFrameName, + clickedLinkFrameNames.foreground, + clickedLinkFrameNames.background, process.platform === 'darwin' ) } @@ -43,36 +52,50 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig ) .catch(() => {}) } - if (clickedLinkFrameName) { + if (clickedLinkFrameNames) { guest.on('dom-ready', installClickedLinkRouting) } const pendingIframeRoutingInstalls = new Map void>() - const iframeFrameNameByFrame = new Map() - const iframeFrameByFrameName = new Map() + const iframeFrameNamesByFrame = new Map() + const iframeRoutingByFrameName = new Map< + string, + { frame: Electron.WebFrameMain; activate: boolean } + >() const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { - const name = iframeFrameNameByFrame.get(frame) - if (!name) { + const names = iframeFrameNamesByFrame.get(frame) + if (!names) { return } - iframeFrameNameByFrame.delete(frame) - iframeFrameByFrameName.delete(name) + iframeFrameNamesByFrame.delete(frame) + for (const name of [names.foreground, names.background]) { + iframeRoutingByFrameName.delete(name) + } } const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { clearIframeFrameName(frame) if (!clickedLinkRoutingActive || frame.isDestroyed()) { return } - const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` - iframeFrameNameByFrame.set(frame, name) - iframeFrameByFrameName.set(name, frame) + const foregroundName = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + const backgroundName = `__orca_clicked_link_iframe_background_${randomUUID()}` + iframeFrameNamesByFrame.set(frame, { + foreground: foregroundName, + background: backgroundName + }) + iframeRoutingByFrameName.set(foregroundName, { frame, activate: true }) + iframeRoutingByFrameName.set(backgroundName, { frame, activate: false }) // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. void frame .executeJavaScript( - buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + buildBrowserIframeClickedLinkRoutingScript( + foregroundName, + backgroundName, + process.platform === 'darwin' + ), false ) .catch(() => { - if (iframeFrameNameByFrame.get(frame) === name) { + if (iframeFrameNamesByFrame.get(frame)?.foreground === foregroundName) { clearIframeFrameName(frame) } }) @@ -81,10 +104,10 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig _event: Electron.Event, { frame }: Electron.FrameCreatedDetails ): void => { - if (!clickedLinkFrameName || !frame || frame.parent === null) { + if (!clickedLinkFrameNames || !frame || frame.parent === null) { return } - for (const knownFrame of iframeFrameNameByFrame.keys()) { + for (const knownFrame of iframeFrameNamesByFrame.keys()) { if (knownFrame.isDestroyed()) { clearIframeFrameName(knownFrame) } @@ -96,7 +119,7 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig pendingIframeRoutingInstalls.set(frame, installAfterDomReady) frame.once('dom-ready', installAfterDomReady) } - if (clickedLinkFrameName) { + if (clickedLinkFrameNames) { guest.on('frame-created', handleFrameCreated) } const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { @@ -109,19 +132,28 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig const browserTabId = ownerContext?.browserTabId ?? null const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) - const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) - const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined - let isClickedLink = Boolean( - expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName - ) - if (!isClickedLink && iframeFrame) { - isClickedLink = true - clearIframeFrameName(iframeFrame) - queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + const expectedClickedLinkFrameNames = clickedLinkRoutingActive ? clickedLinkFrameNames : null + const iframeRouting = frameName ? iframeRoutingByFrameName.get(frameName) : undefined + let clickedLinkActivate: boolean | null = null + if (expectedClickedLinkFrameNames && frameName === expectedClickedLinkFrameNames.foreground) { + clickedLinkActivate = true + } else if ( + expectedClickedLinkFrameNames && + frameName === expectedClickedLinkFrameNames.background + ) { + clickedLinkActivate = false + } else if (iframeRouting) { + clickedLinkActivate = iframeRouting.activate + clearIframeFrameName(iframeRouting.frame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeRouting.frame)) } - if (isClickedLink) { - if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + if (clickedLinkActivate !== null) { + if ( + browserTabId && + browserUrl && + this.openLinkInOrcaTab(browserTabId, browserUrl, clickedLinkActivate) + ) { this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(browserUrl), action: 'opened-in-orca' @@ -148,7 +180,13 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig }) return { action: 'deny' } } - if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + if ( + this.openLinkInOrcaTab( + ownerContext.browserTabId, + externalUrl, + disposition !== 'background-tab' + ) + ) { this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(externalUrl), action: 'opened-in-orca' @@ -190,7 +228,7 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig clickedLinkRoutingActive = false try { guest.off('did-create-window', handleDidCreateWindow) - if (clickedLinkFrameName) { + if (clickedLinkFrameNames) { guest.off('dom-ready', installClickedLinkRouting) guest.off('frame-created', handleFrameCreated) for (const [frame, install] of pendingIframeRoutingInstalls) { @@ -199,8 +237,8 @@ export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavig } } pendingIframeRoutingInstalls.clear() - iframeFrameNameByFrame.clear() - iframeFrameByFrameName.clear() + iframeFrameNamesByFrame.clear() + iframeRoutingByFrameName.clear() } } catch { // guest may already be destroyed diff --git a/src/main/browser/browser-manager-popup-routing.test.ts b/src/main/browser/browser-manager-popup-routing.test.ts index a3083005016..c0247796d09 100644 --- a/src/main/browser/browser-manager-popup-routing.test.ts +++ b/src/main/browser/browser-manager-popup-routing.test.ts @@ -263,6 +263,11 @@ describe('browserManager', () => { browserPageId: 'browser-1', url: 'https://docs.example.com/guide' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://docs.example.com/guide', + activate: false + }) expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { browserPageId: 'browser-1', origin: 'https://docs.example.com', @@ -416,20 +421,21 @@ describe('browserManager', () => { domReadyHandler?.() await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledTimes(1)) - const managerState = browserManager as unknown as { - clickedLinkFrameNameByGuestId: Map + const script = executeJavaScriptInIsolatedWorldMock.mock.calls[0][1][0].code as string + const clickedLinkFrameNames = { + foreground: script.match(/__orca_clicked_link_foreground_[0-9a-f-]+/)?.[0], + background: script.match(/__orca_clicked_link_background_[0-9a-f-]+/)?.[0] } - const clickedLinkFrameName = managerState.clickedLinkFrameNameByGuestId.get(guest.id) - if (!clickedLinkFrameName) { - throw new Error('Expected a private clicked-link frame name') + if (!clickedLinkFrameNames.foreground || !clickedLinkFrameNames.background) { + throw new Error('Expected private clicked-link frame names') } - expect(clickedLinkFrameName).toMatch(/^__orca_clicked_link_foreground_/) + expect(clickedLinkFrameNames.foreground).toMatch(/^__orca_clicked_link_foreground_/) expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledWith( expect.any(Number), [ expect.objectContaining({ code: expect.stringContaining( - `${JSON.stringify(clickedLinkFrameName)},${process.platform === 'darwin'})` + `${JSON.stringify(clickedLinkFrameNames.foreground)},${JSON.stringify(clickedLinkFrameNames.background)},${process.platform === 'darwin'})` ) }) ], @@ -443,13 +449,14 @@ describe('browserManager', () => { expect( handler({ url: 'https://docs.example.com/guide', - frameName: clickedLinkFrameName + frameName: clickedLinkFrameNames.background }) ).toEqual({ action: 'deny' }) expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { browserPageId: 'browser-1', - url: 'https://docs.example.com/guide' + url: 'https://docs.example.com/guide', + activate: false }) expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { browserPageId: 'browser-1', @@ -512,9 +519,15 @@ describe('browserManager', () => { const foregroundFrameName = firstScript.match( /__orca_clicked_link_iframe_foreground_[0-9a-f-]+/ )?.[0] + const backgroundFrameName = firstScript.match( + /__orca_clicked_link_iframe_background_[0-9a-f-]+/ + )?.[0] if (!foregroundFrameName) { throw new Error('Expected a private child-frame routing token') } + if (!backgroundFrameName) { + throw new Error('Expected a private background child-frame routing token') + } expect(firstScript).toContain('installBrowserIframeClickedLinkRouting') expect(executeJavaScriptMock).toHaveBeenCalledWith(firstScript, false) @@ -525,17 +538,26 @@ describe('browserManager', () => { expect( popupHandler({ url: 'https://docs.example.com/from-frame', - frameName: foregroundFrameName + frameName: backgroundFrameName }) ).toEqual({ action: 'deny' }) expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { browserPageId: 'browser-frame', - url: 'https://docs.example.com/from-frame' + url: 'https://docs.example.com/from-frame', + activate: false }) + expect( + popupHandler({ + url: 'https://docs.example.com/from-frame-sibling', + frameName: foregroundFrameName + }) + ).toMatchObject({ action: 'allow' }) + expect(rendererSendMock).toHaveBeenCalledTimes(2) await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(2)) const secondScript = executeJavaScriptMock.mock.calls[1][0] as string expect(secondScript).not.toContain(foregroundFrameName) + expect(secondScript).not.toContain(backgroundFrameName) }) it('hosts allowed popups in an origin-bar window with inherited guest policies', () => { diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts index 6850c240950..ba2ac8647eb 100644 --- a/src/main/browser/browser-manager-registration.ts +++ b/src/main/browser/browser-manager-registration.ts @@ -206,7 +206,6 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli cleanup() } this.policyCleanupByGuestId.clear() - this.clickedLinkFrameNameByGuestId.clear() this.tabIdByWebContentsId.clear() this.popupOwnerContextByGuestId.clear() this.pageInitiatedTabBudgetByRootGuestId.clear() diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index 54b0f99b3c1..fef6eee3f9a 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -102,7 +102,11 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt error: string | null ): void protected abstract getDownloadReceivedBytes(item: Electron.DownloadItem): number - protected abstract openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean + protected abstract openLinkInOrcaTab( + browserTabId: string, + rawUrl: string, + activate?: boolean + ): boolean protected settingsResolver: | (() => { @@ -143,7 +147,6 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt protected readonly policyAttachedGuestIds = new Set() protected readonly offscreenGuestIds = new Set() protected readonly policyCleanupByGuestId = new Map void>() - protected readonly clickedLinkFrameNameByGuestId = new Map() protected readonly loadErrorsByGuestId = new Map() // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. protected readonly clearedLoadErrorsByGuestId = new Map() diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index c4f1f9a6fca..eb69a66a897 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -566,7 +566,7 @@ describe('Claude stream-json connection', () => { ) }) - it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => { + it('reports a self-exit with its status, stderr, and observed tree verdict', async () => { const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }]) let exit: Error | null = null const connection = await open(launchFor(scenario), { @@ -579,10 +579,11 @@ describe('Claude stream-json connection', () => { // The status and stderr are the only diagnostic a refused start leaves behind. expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) expect(connection.closed).toBe(true) - // The root's exit is first-hand, but it left before a descendant snapshot - // could be armed, so close() has no tree proof to offer and says so. - await expect(connection.close()).resolves.toBe(false) - expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' }) + // Stderr-triggered capture can win or lose the race with this real child's exit. + const closed = await connection.close() + expect(connection.exitVerdict.root).toBe('exited') + expect(['exited', 'unverifiable']).toContain(connection.exitVerdict.tree) + expect(closed).toBe(connection.exitVerdict.tree === 'exited') }) it.runIf(process.platform !== 'win32')( diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index a90cb7908ba..168ce558f53 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -158,4 +158,42 @@ describe('stopClaudeBackgroundTasks', () => { await stopClaudeBackgroundTasks(session, undefined, () => current) expect(stopTask).toHaveBeenCalledTimes(1) }) + + it('stops only the requested live task id', async () => { + const backgroundTasks = new ClaudeBackgroundTaskTracker() + backgroundTasks.observe({ + type: 'system', + subtype: 'background_tasks_changed', + tasks: [ + { task_id: 'task-one', task_type: 'local_agent' }, + { task_id: 'task-two', task_type: 'local_bash' } + ] + }) + const stopTask = vi.fn(async (_taskId: string) => {}) + const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + + await expect( + stopClaudeBackgroundTasks(session, 5_000, () => true, 'task-two') + ).resolves.toEqual({ cancelled: true }) + expect(stopTask).toHaveBeenCalledWith('task-two', { timeoutMs: 5_000 }) + expect(stopTask).toHaveBeenCalledTimes(1) + }) + + it('refuses a stale or unknown task id without a provider call', async () => { + const backgroundTasks = new ClaudeBackgroundTaskTracker() + backgroundTasks.observe({ + type: 'system', + subtype: 'task_started', + task_id: 'task-live', + task_type: 'local_agent', + is_backgrounded: true + }) + const stopTask = vi.fn(async (_taskId: string) => {}) + const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + + await expect( + stopClaudeBackgroundTasks(session, undefined, () => true, 'task-stale') + ).resolves.toEqual({ cancelled: false }) + expect(stopTask).not.toHaveBeenCalled() + }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index d216304c311..8b3bb94c7b5 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -46,9 +46,12 @@ export async function cancelClaudeTurn( export async function stopClaudeBackgroundTasks( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true + isCurrent: ClaudeTurnCancellationGuard = () => true, + taskId?: string ): Promise<{ cancelled: boolean }> { - const taskIds = session.backgroundTasks.stoppableTaskIds + const stoppableTaskIds = session.backgroundTasks.stoppableTaskIds + const taskIds = + taskId === undefined ? stoppableTaskIds : stoppableTaskIds.includes(taskId) ? [taskId] : [] let cancelled = false for (const taskId of taskIds) { if (!isCurrent()) { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 9bd92e1e839..c00a588e891 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -30,6 +30,7 @@ import { settleClaudeExitedSession } from './claude-structured-session-close' import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -40,6 +41,11 @@ export type { const DISPATCH_ACK_TIMEOUT_MS = 10_000 +function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null { + const state = session.backgroundTasks.state + return state ? { ...state, supportsTaskStop: true } : null +} + export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() @@ -192,7 +198,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda session?.translator?.handle(event) this.deps.onEvent?.(event) if (backgroundTasksChanged) { - this.deps.onBackgroundTasksChanged?.(event.sessionId, session?.backgroundTasks.state ?? null) + this.deps.onBackgroundTasksChanged?.( + event.sessionId, + session ? backgroundTaskState(session) : null + ) } } @@ -233,18 +242,25 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration - return stopClaudeBackgroundTasks(session, this.deps.requestTimeoutMs, () => - Boolean( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - session.backgroundTasks.state - ) + return stopClaudeBackgroundTasks( + session, + this.deps.requestTimeoutMs, + () => + Boolean( + this.sessions.get(input.sessionId) === session && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration && + session.backgroundTasks.state + ), + input.taskId ) } backgroundTaskState: NonNullable = ( sessionId - ) => this.sessions.get(sessionId)?.backgroundTasks.state + ) => { + const session = this.sessions.get(sessionId) + return session ? backgroundTaskState(session) : undefined + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => answerClaudePrompt(this.session(input.sessionId), input) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 0df0e913e50..670c0daaf8c 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -53,7 +53,11 @@ describe('Claude published session close lifecycle', () => { is_backgrounded: true }) expect(backgroundStates).toEqual([ - { state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] } + { + state: 'monitoring', + tasks: [{ id: 'background-1', kind: 'agent' }], + supportsTaskStop: true + } ]) const session = ( adapter as unknown as { @@ -68,7 +72,11 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() expect(backgroundStates).toEqual([ - { state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] }, + { + state: 'monitoring', + tasks: [{ id: 'background-1', kind: 'agent' }], + supportsTaskStop: true + }, null ]) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index e2937015bea..a4e48c98120 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { vi, describe, expect, it } from 'vitest' +import type * as GitBashModule from '../git-bash' vi.mock('electron', () => ({ app: { @@ -14,11 +15,23 @@ vi.mock('electron', () => ({ } })) +// Why: the installed hook shape depends on whether Git Bash is resolvable on the host, so the +// install assertions below have to state which host they describe rather than inherit the box's. +const { gitBashAvailableMock } = vi.hoisted(() => ({ gitBashAvailableMock: { value: true } })) +vi.mock('../git-bash', async (importOriginal) => ({ + ...(await importOriginal()), + isGitBashAvailable: () => gitBashAvailableMock.value +})) + import type { SFTPWrapper } from 'ssh2' -import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' +import { createManagedCommandMatcher, WINDOWS_CMD_SAFE_PATH } from '../agent-hooks/installer-utils' import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from '../agent-hooks/hook-stdin-contract' import { ClaudeHookService } from './hook-service' -import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' +import { + CLAUDE_EVENTS, + getWindowsManagedLifecycleHook, + OPENCLAUDE_HOOK_SETTINGS +} from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' const STATUSLINE_SCRIPT_FILE_NAME = @@ -35,14 +48,29 @@ function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined } describe('getWindowsManagedLifecycleHook', () => { - it('resolves the managed script from the runtime Windows profile, as a single command string', () => { - const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd' - const hook = getWindowsManagedLifecycleHook(scriptPath) + const SAFE_SCRIPT_PATH = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' + const UNSAFE_SCRIPT_PATH = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd' + + it('registers the script itself, with no interpreter in front of it (#18875)', () => { + // Why this is the whole point: the encoded launcher spent a PowerShell start-up per hook + // event (471ms vs 201ms measured) before the .cmd could reach its ORCA_PANE_KEY guard, and + // its orphan outlived the hook's timeout kill still holding the stdout the agent reads. + const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true }) + + expect(hook.args).toBeUndefined() + expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}') + expect(hook.command).not.toMatch(/powershell|-EncodedCommand|conhost/i) + // Why: Git Bash/MSYS mangles backslash paths and rewrites slash-prefixed switches. + expect(hook.command).not.toMatch(/\\/) + expect(hook.command).not.toMatch(/ \/[a-zA-Z]+( |$)/) + }) + + it('falls back to the encoded launcher when the profile path is not cmd-safe', () => { + const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH, { gitBashAvailable: true }) expect(hook.args).toBeUndefined() expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) - expect(hook.command).not.toContain(scriptPath) - // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. + expect(hook.command).not.toContain(UNSAFE_SCRIPT_PATH) expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/) const encoded = hook.command.match(/-EncodedCommand (\S+)$/)?.[1] @@ -51,10 +79,21 @@ describe('getWindowsManagedLifecycleHook', () => { expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd') }) + it('falls back to the encoded launcher when Git Bash is not resolvable', () => { + // Why: without Git Bash, Claude Code hosts the hook in PowerShell, and PowerShell 5.1 + // rejects `||` as a statement separator (measured) — every event would be a parse error. + const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false }) + + expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) + }) + it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => { - const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd' - const hook = getWindowsManagedLifecycleHook(scriptPath) - expect(isClaudeManagedCommand(hook.command)).toBe(true) + for (const hook of [ + getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true }), + getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false }) + ]) { + expect(isClaudeManagedCommand(hook.command)).toBe(true) + } }) }) @@ -200,7 +239,13 @@ describe('ClaudeHookService.install', () => { const managedHook = legacyHooks.find((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand) ) - expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/')) + // Why: POSIX resolves the profile at runtime (`${HOME-}`, STA-3348). Windows cannot — + // no single token expands in both Git Bash and cmd.exe — so it registers the absolute + // path, as Codex/Grok/Devin/Antigravity already do (#18875). A moved profile is caught + // by getStatus's exact match and rewritten, and `|| echo {}` keeps a stale entry neutral. + if (process.platform !== 'win32') { + expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/')) + } expect( legacyHooks.some((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand)) ).toBe(true) @@ -365,7 +410,7 @@ describe('ClaudeHookService.install', () => { }) it.skipIf(process.platform !== 'win32')( - 'runs portable managed hooks through a single headless command string', + 'pins the encoded-launcher fallback for a profile path the shells cannot carry bare', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces ')) vi.stubEnv('HOME', tmpHome) @@ -397,6 +442,137 @@ describe('ClaudeHookService.install', () => { } ) + it.skipIf(process.platform !== 'win32')( + 'installs the bare script path on every event when the profile path is cmd-safe (#18875)', + () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-direct-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) + // Why: a runner whose tmpdir carries a space (a profile-scoped TEMP) belongs to the + // fallback case above, not this one; skip rather than assert the wrong contract. + if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + return + } + try { + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse( + readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') + ) as { hooks: Record } + + const expected = `${scriptPath.replaceAll('\\', '/')} || echo {}` + for (const { eventName } of CLAUDE_EVENTS) { + const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] + expect(hook?.args, eventName).toBeUndefined() + expect(hook?.command, eventName).toBe(expected) + } + // Why: the whole point of #18875 — no interpreter is started to reach the script. + expect(JSON.stringify(settings.hooks)).not.toMatch(/powershell|EncodedCommand/i) + expect(new ClaudeHookService().getStatus().state).toBe('installed') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform !== 'win32')( + 'sweeps a previously installed encoded launcher on reinstall, keeping user hooks', + () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-migrate-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) + if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + return + } + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + const stale = getWindowsManagedLifecycleHook(scriptPath, { gitBashAvailable: false }) + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + Stop: [{ hooks: [stale] }], + PreToolUse: [{ matcher: '*', hooks: [stale] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'echo mine' }] }] + } + }), + 'utf-8' + ) + + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as { + hooks: Record + } + expect(JSON.stringify(settings.hooks)).not.toContain('-EncodedCommand') + expect( + settings.hooks.UserPromptSubmit.some((definition) => + definition.hooks.some((hook) => hook.command === 'echo mine') + ) + ).toBe(true) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform !== 'win32')( + 'reports a stale absolute path as not_installed and rewrites it on install (#18875)', + () => { + // Why: the direct shape bakes the profile path in, where the encoded launcher resolved + // %USERPROFILE% at run time (STA-3348). That is only safe because a moved profile is + // caught here and rewritten, so this is the test that carries the replaced contract. + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-moved-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME) + if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + return + } + try { + const settingsPath = join(tmpHome, '.claude', 'settings.json') + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + const staleCommand = 'C:/Users/someone-else/.orca/agent-hooks/claude-hook.cmd || echo {}' + const stale = { type: 'command', command: staleCommand, timeout: 10 } + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: Object.fromEntries( + CLAUDE_EVENTS.map(({ eventName }) => [eventName, [{ hooks: [stale] }]]) + ) + }), + 'utf-8' + ) + + expect(new ClaudeHookService().getStatus().state).toBe('not_installed') + expect(new ClaudeHookService().install().state).toBe('installed') + + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as { + hooks: Record + } + expect(JSON.stringify(settings.hooks)).not.toContain('someone-else') + expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe( + `${scriptPath.replaceAll('\\', '/')} || echo {}` + ) + expect(new ClaudeHookService().getStatus().state).toBe('installed') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + } + ) + it.skipIf(process.platform !== 'win32')( 'posts from the managed .cmd via curl.exe, not a second PowerShell', () => { diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index c6cf3a9b53c..047fcbb26b6 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -14,23 +14,25 @@ import { type HooksConfig } from '../agent-hooks/installer-utils' import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' +import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' +import { isGitBashAvailable } from '../git-bash' export type ClaudeCompatibleHookSettings = { configDirName: '.claude' | '.openclaude' scriptBaseName: 'claude-hook' | 'openclaude-hook' - usesWindowsPowerShellLauncher: boolean + usesWindowsCompatLauncher: boolean } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.claude', scriptBaseName: 'claude-hook', - usesWindowsPowerShellLauncher: true + usesWindowsCompatLauncher: true } export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { configDirName: '.openclaude', scriptBaseName: 'openclaude-hook', - usesWindowsPowerShellLauncher: false + usesWindowsCompatLauncher: false } export const CLAUDE_EVENTS = [ @@ -153,16 +155,31 @@ export function getManagedCommand( export function getManagedLifecycleHook( scriptPath: string, - settings = CLAUDE_HOOK_SETTINGS + settings = CLAUDE_HOOK_SETTINGS, + options: WindowsManagedLifecycleHookOptions = {} ): HookCommandConfig { - if (process.platform !== 'win32' || !settings.usesWindowsPowerShellLauncher) { + if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) { return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) } - return getWindowsManagedLifecycleHook(scriptPath) + return getWindowsManagedLifecycleHook(scriptPath, options) } +export type WindowsManagedLifecycleHookOptions = { gitBashAvailable?: boolean } + // Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained. -export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig { +export function getWindowsManagedLifecycleHook( + scriptPath: string, + options: WindowsManagedLifecycleHookOptions = {} +): HookCommandConfig { + // Why (#18875): the encoded launcher cost a PowerShell start-up per hook event. Take the direct + // path only where the host can parse `||` — Git Bash can, Windows PowerShell 5.1 cannot. + const directCommand = + (options.gitBashAvailable ?? isGitBashAvailable()) + ? wrapWindowsDirectCmdHookCommand(scriptPath) + : null + if (directCommand) { + return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } + } const scriptFileName = win32.basename(scriptPath) // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). const quotedRelativePath = quotePowerShellString(`.orca\\agent-hooks\\${scriptFileName}`) diff --git a/src/main/codex/codex-command-action-class.ts b/src/main/codex/codex-command-action-class.ts new file mode 100644 index 00000000000..81360691ef9 --- /dev/null +++ b/src/main/codex/codex-command-action-class.ts @@ -0,0 +1,71 @@ +import { readRecord, readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +/** + * Codex's own classification of a shell call: the tool name to show, and the + * fields worth lifting into `input` for the shared label helper (a file target, + * a search term, a scanned root). A `Map`, not an object — an object index + * answers `__proto__` with a truthy non-string. Every other action type stays an + * unclassified `shell` row. + * + * Nothing is invented for a field Codex sends as null: a stand-in path is a + * claim about a target, and the label helper turns any path into a file link. + */ +type CommandActionClass = { + name: string + /** Action field to the `input` key it lifts to. A scan root and a listed + * directory lift to `directory`, never `path`: the label helper reads `path` + * as a file target, which mobile turns into a tappable open-file link. */ + keys: Readonly> +} + +const COMMAND_ACTION_CLASSES = new Map([ + ['read', { name: 'read', keys: { path: 'path' } }], + ['search', { name: 'search', keys: { query: 'query', path: 'directory' } }], + ['listFiles', { name: 'list', keys: { path: 'directory' } }] +]) + +/** The one class every classified `commandActions` entry agrees on, with the + * fields they all agree on; null leaves the row exactly as a Codex that sends no + * classification renders it. `cat a.txt && ls src` classifies as two different + * things, and naming that row after either would drop the other, so it stays a + * `shell` row that shows the whole command. */ +export function commandActionFacts( + item: CodexThreadItem +): { name: string; fields: Record } | null { + const actions = item.commandActions + if (!Array.isArray(actions)) { + return null + } + let matched: { class: CommandActionClass; fields: Record } | null = null + for (const action of actions) { + const record = readRecord(action) + const type = readString(record, 'type') + const classified = type === null ? undefined : COMMAND_ACTION_CLASSES.get(type) + if (classified === undefined) { + continue + } + if (matched === null) { + const fields: Record = {} + for (const [source, lifted] of Object.entries(classified.keys)) { + const value = readString(record, source) + if (value !== null) { + fields[lifted] = value + } + } + matched = { class: classified, fields } + continue + } + if (matched.class.name !== classified.name) { + return null + } + // The same class twice keeps the class, but only a target both entries name. + for (const [source, lifted] of Object.entries(matched.class.keys)) { + const kept = matched.fields[lifted] + if (kept !== undefined && readString(record, source) !== kept) { + delete matched.fields[lifted] + } + } + } + return matched === null ? null : { name: matched.class.name, fields: matched.fields } +} diff --git a/src/main/codex/codex-item-field-readers.ts b/src/main/codex/codex-item-field-readers.ts new file mode 100644 index 00000000000..bbe2551615a --- /dev/null +++ b/src/main/codex/codex-item-field-readers.ts @@ -0,0 +1,45 @@ +// Field readers for the loosely-typed records Codex sends on thread items. + +export function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +export function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function readFirstString( + source: Record, + keys: readonly string[] +): string | null { + for (const key of keys) { + const value = readString(source, key) + if (value !== null) { + return value + } + } + return null +} + +export function readTextContent(source: Record, key: string): string | null { + const direct = readString(source, key) + if (direct) { + return direct + } + const value = source[key] + if (!Array.isArray(value)) { + return null + } + const parts = value.flatMap((part) => { + if (typeof part === 'string') { + return part.length > 0 ? [part] : [] + } + if (typeof part !== 'object' || part === null) { + return [] + } + const text = readString(part as Record, 'text') + return text ? [text] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index f0f843e4ed6..2558f4b60de 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + briefToolArg, + createToolInputDisplay, + describeToolInput +} from '../../shared/native-chat-tool-summary' import { codexItemBody, codexItemIdentity, @@ -13,6 +18,13 @@ import { type CodexThreadItem } from './codex-structured-item-translation' +/** The tool-call input a Codex item lands on, which is what the row label and + * the collapsed run header are both derived from. */ +function toolCallInput(item: CodexThreadItem): unknown { + const body = codexItemBody(item) + return body !== null && body.kind === 'tool-call' ? body.input : null +} + const THREAD_ID = 'thread-abc' const TURN_ID = 'turn-1' @@ -195,6 +207,279 @@ describe('codex item bodies', () => { }) }) + it('names a classified read command by its class and keeps the raw command', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-read', + command: "sed -n '1,200p' notes.txt", + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { + type: 'read', + command: "sed -n '1,200p' notes.txt", + name: 'notes.txt', + path: '/repo/notes.txt' + } + ] + }) + + expect(body).toEqual({ + kind: 'tool-call', + name: 'read', + // `name` is the target's basename, which `path` already carries and no + // label ever reads, so it stays out of the bounded journal payload. + input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' }, + state: 'completed' + }) + // `read` is the one class that keeps `path`, so its row stays a tappable + // file on mobile — the other half of the rule `list`/`search` obey below. + const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null) + expect(display.filePath).toBe('/repo/notes.txt') + expect(display.label).toBe('/repo/notes.txt') + }) + + it('carries a classified search query so the row labels by term, not scan root', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-search', + command: 'rg -n --no-heading beta .', + cwd: '/repo', + status: 'inProgress', + commandActions: [ + { type: 'search', command: 'rg -n --no-heading beta .', query: 'beta', path: '.' } + ] + }) + ).toEqual({ + kind: 'tool-call', + name: 'search', + input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' }, + state: 'running' + }) + }) + + it('omits a null classified field rather than standing it in as a target', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-search-bare', + command: 'rg beta', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'search', command: 'rg beta', query: null, path: null }] + }) + ).toEqual({ + kind: 'tool-call', + name: 'search', + input: { command: 'rg beta', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('names a classified listFiles command `list` and invents no target for a null path', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-list', + command: 'ls', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'listFiles', command: 'ls', path: null }] + }) + + expect(body).toEqual({ + kind: 'tool-call', + name: 'list', + input: { command: 'ls', cwd: '/repo' }, + state: 'completed' + }) + // A stand-in `.` reaches mobile as a tappable "open file" link onto a + // directory, which can only fail. The raw command is the honest label. + const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null) + expect(display.filePath).toBeNull() + expect(display.label).toBe('ls') + }) + + it('keeps the shell row when one command did two different classified things', () => { + // `cat a.txt && ls src` classifies as a read and a listing; naming the row + // after either drops the other. + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-mixed', + command: 'cat a.txt && ls src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'read', command: 'cat a.txt', name: 'a.txt', path: 'a.txt' }, + { type: 'listFiles', command: 'ls src', path: 'src' } + ] + }) + ).toEqual({ + kind: 'tool-call', + name: 'shell', + input: { command: 'cat a.txt && ls src', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('keeps one class run twice, naming no target when the two disagree', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-two-reads', + command: 'cat a.ts && cat b.ts', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'read', command: 'cat a.ts', path: 'a.ts' }, + { type: 'read', command: 'cat b.ts', path: 'b.ts' } + ] + }) + ).toEqual({ + kind: 'tool-call', + name: 'read', + input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('keeps a target both entries of one class name', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-same-read', + command: 'head a.ts && tail a.ts', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'read', command: 'head a.ts', path: 'a.ts' }, + { type: 'read', command: 'tail a.ts', path: 'a.ts' } + ] + }) + ).toMatchObject({ name: 'read', input: { path: 'a.ts' } }) + }) + + it('keeps the listed directory as a label, never as a file target', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-list-path', + command: 'ls src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'listFiles', command: 'ls src', path: 'src' }] + }) + + expect(body).toMatchObject({ name: 'list', input: { directory: 'src' } }) + // Under `path` this reaches mobile as a tappable open-file link onto a + // directory — the same dead link a stand-in `.` would have produced. + const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null) + expect(display.filePath).toBeNull() + expect(display.label).toBe('src') + }) + + it('keeps a scan root off the file-target key even when the search has no term', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-search-root', + command: 'rg --files src', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'search', command: 'rg --files src', query: null, path: 'src' }] + }) + + expect(body).toMatchObject({ name: 'search', input: { directory: 'src' } }) + // `path` is only excluded from the file target while a query is present, so + // a term-less search under it would link to the folder it scanned. + expect( + createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null).filePath + ).toBeNull() + }) + + it('leaves the other classes without a stand-in target', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-read-null', + command: 'cat', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [{ type: 'read', command: 'cat', path: null, name: null }] + }) + ).toEqual({ + kind: 'tool-call', + name: 'read', + input: { command: 'cat', cwd: '/repo' }, + state: 'completed' + }) + }) + + it('skips unclassified actions to reach the first classified one', () => { + expect( + codexItemBody({ + type: 'commandExecution', + id: 'item-piped', + command: 'true && cat a.ts', + cwd: '/repo', + status: 'completed', + exitCode: 0, + commandActions: [ + { type: 'unknown', command: 'true' }, + { type: 'read', command: 'cat a.ts', name: 'a.ts', path: 'a.ts' } + ] + }) + ).toMatchObject({ name: 'read', input: { path: 'a.ts' } }) + }) + + it('falls back to the unclassified shell row for absent or malformed commandActions', () => { + const shellRow = { + kind: 'tool-call', + name: 'shell', + input: { command: 'ls', cwd: '/tmp' }, + state: 'completed' + } + const base = { + type: 'commandExecution', + id: 'item-fallback', + command: 'ls', + cwd: '/tmp', + status: 'completed', + exitCode: 0 + } + + expect(codexItemBody(base)).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: null })).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: [] })).toEqual(shellRow) + expect( + codexItemBody({ ...base, commandActions: [{ type: 'unknown', command: 'ls' }] }) + ).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: 'read' })).toEqual(shellRow) + expect(codexItemBody({ ...base, commandActions: [null, 7, 'read', {}, { type: 5 }] })).toEqual( + shellRow + ) + // The classification table is a Map because an object index answers + // `__proto__`/`constructor` with a truthy non-string tool name. + expect( + codexItemBody({ ...base, commandActions: [{ type: '__proto__', command: 'ls' }] }) + ).toEqual(shellRow) + expect( + codexItemBody({ ...base, commandActions: [{ type: 'constructor', command: 'ls' }] }) + ).toEqual(shellRow) + // The rollout-file shape is a different lane and never reaches app-server. + expect( + codexItemBody({ ...base, parsedCmd: [{ type: 'read', cmd: 'ls', path: 'a.ts' }] }) + ).toEqual(shellRow) + }) + it('accepts snake-case command completion output and preserves blob evidence', () => { const output = 'x'.repeat(1_100_000) const translated = codexJournalItem({ @@ -298,10 +583,226 @@ describe('codex item bodies', () => { }) expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() - expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({ + expect(codexItemBody({ type: 'somethingCodexAddedLater', id: 'x' })).toMatchObject({ kind: 'status', - text: 'codex · item:webSearch', - providerFrame: { provider: 'codex', kind: 'item:webSearch' } + text: 'codex · item:somethingCodexAddedLater', + providerFrame: { provider: 'codex', kind: 'item:somethingCodexAddedLater' } + }) + }) + + it('gives an mcp tool call a typed body with its own arguments as input', () => { + expect( + codexItemBody({ + type: 'mcpToolCall', + id: 'mcp-1', + server: 'weather', + tool: 'get_forecast', + status: 'completed', + arguments: { city: 'Oslo' }, + result: { content: [{ type: 'text', text: '12C' }] } + }) + ).toEqual({ + kind: 'tool-call', + // Server-qualified, and the arguments stay top level so the row label can + // read `query`/`command`/`file_path` out of them. + name: 'weather/get_forecast', + input: { city: 'Oslo' }, + state: 'completed', + output: { head: '12C', byteLength: 3, truncated: false, digest: expect.any(String) } + }) + }) + + it('passes an mcp tool name through with no casing transform', () => { + // Downstream dispatch is exact-match on raw identifiers, so every shape — + // bare snake_case included — has to survive byte-identical. + for (const tool of ['get_forecast', 'mcp__server__tool', 'ns.tool', 'urn:tool', 'listTools']) { + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool, status: 'inProgress' }), + tool + ).toMatchObject({ kind: 'tool-call', name: tool, state: 'running' }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'srv', tool, status: 'inProgress' }), + tool + ).toMatchObject({ kind: 'tool-call', name: `srv/${tool}`, state: 'running' }) + } + }) + + it('falls back to the bare tool, then to `mcp`, when the item is under-specified', () => { + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 'get_forecast', status: 'inProgress' }) + ).toMatchObject({ name: 'get_forecast' }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', server: '', tool: 'ping', status: 'completed' }) + ).toMatchObject({ name: 'ping' }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'weather', status: 'completed' }) + ).toMatchObject({ name: 'mcp' }) + }) + + it('keeps non-object mcp arguments addressable and empty ones off the label', () => { + // `arguments` is arbitrary JSON upstream; a scalar or array must still reach + // the row rather than being dropped or unwrapped into a bare value. + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: 'raw text' }) + ).toMatchObject({ input: { arguments: 'raw text' } }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: [1, 2] }) + ).toMatchObject({ input: { arguments: [1, 2] } }) + // `arguments` is required on the wire, so `{}` — not an absent key — is what + // an argument-less MCP tool sends, and passing it through labels the row `{}`. + expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({ + kind: 'tool-call', + name: 't', + input: null, + state: 'running' + }) + expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't' })).toMatchObject({ + input: null + }) + expect( + codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: null }) + ).toMatchObject({ input: null }) + }) + + it('renders an argument-less mcp call as a bare server/tool row', () => { + const input = toolCallInput({ + type: 'mcpToolCall', + id: 'm', + server: 'srv', + tool: 'list_tools', + arguments: {} + }) + expect(describeToolInput(input)).toBe('') + expect(briefToolArg(input)).toBe('') + }) + + it('reports an mcp error as a failed call carrying the server message', () => { + expect( + codexItemBody({ + type: 'mcpToolCall', + id: 'mcp-2', + server: 's', + tool: 'ping', + status: 'completed', + error: { message: 'server unreachable' } + }) + ).toMatchObject({ + kind: 'tool-call', + name: 's/ping', + state: 'failed', + output: { head: 'server unreachable', truncated: false } + }) + }) + + it('models a web search as a tool call that runs until codex sends the action', () => { + // The start frame Codex actually emits: empty query, no action. Nothing is + // labelable yet, so the input is absent rather than a hull of null keys. + expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({ + kind: 'tool-call', + name: 'web_search', + input: null, + state: 'running' + }) + expect( + codexItemBody({ + type: 'webSearch', + id: 'w', + query: 'orca release notes', + action: { type: 'search', query: 'orca release notes', queries: null }, + results: null + }) + ).toEqual({ + kind: 'tool-call', + name: 'web_search', + input: { + query: 'orca release notes', + description: 'search', + action: { type: 'search', query: 'orca release notes', queries: null } + }, + state: 'completed' + }) + }) + + it('carries the web search hits as the call output', () => { + const results = [{ title: 'Orca 1.0', url: 'https://example.com/notes' }] + expect( + codexItemBody({ + type: 'webSearch', + id: 'w', + query: 'orca release notes', + action: { type: 'search', query: 'orca release notes', queries: null }, + results + }) + ).toMatchObject({ + kind: 'tool-call', + name: 'web_search', + state: 'completed', + output: { head: JSON.stringify(results), truncated: false } + }) + // Nothing to show is no output block at all, not an empty one. + for (const empty of [undefined, null, []]) { + expect( + codexItemBody({ + type: 'webSearch', + id: 'w', + query: 'q', + action: { type: 'search' }, + results: empty + }), + String(empty) + ).not.toHaveProperty('output') + } + }) + + it('labels every web search shape without falling back to raw JSON', () => { + // Both the row label and the run header read top-level input keys only, so a + // shape whose detail sits inside `action` renders as the input's raw JSON. + const url = 'https://example.com/docs/page' + const shapes: [string, unknown, string, string][] = [ + ['started', null, '', ''], + [ + 'search', + { type: 'search', query: 'a sample query', queries: null }, + 'a sample query', + 'a sample query' + ], + ['openPage', { type: 'openPage', url }, url, ''], + [ + 'findInPage', + { type: 'findInPage', url, pattern: 'a needle' }, + 'a sample query', + 'a sample query' + ], + ['other', { type: 'other' }, 'other', ''] + ] + for (const [name, action, label, brief] of shapes) { + // Codex leaves the item's own `query` empty on most completed searches. + const query = name === 'search' || name === 'findInPage' ? 'a sample query' : '' + const input = toolCallInput({ type: 'webSearch', id: 'w', query, action }) + expect(describeToolInput(input), name).toBe(label) + expect(briefToolArg(input), name).toBe(brief) + } + }) + + it('leaves subagent items on the generic row until a real renderer exists', () => { + expect( + codexJournalItem({ + type: 'subAgentActivity', + id: 'a-1', + kind: 'started', + agentThreadId: 'thread-child', + agentPath: '/root/list_directory' + }) + ).toMatchObject({ + handled: false, + body: { kind: 'status', providerFrame: { kind: 'item:subAgentActivity' } } + }) + }) + + it('drops the sleep item, which codex itself renders as nothing', () => { + expect(codexJournalItem({ type: 'sleep', id: 's-1', durationMs: 20_000 })).toEqual({ + body: null, + handled: true }) }) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 19052dca365..ad08525a5f5 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -1,7 +1,4 @@ -import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import { boundInlineText, @@ -9,116 +6,27 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' -import type { CodexTurnOrdinals } from './codex-turn-ordinals' +import { commandActionFacts } from './codex-command-action-class' +import { + readFirstString, + readRecord, + readString, + readTextContent +} from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' +export { + codexItemIdentity, + isCodexMessageItemType, + readCodexThreadItem, + type CodexThreadItem +} from './codex-thread-item-identity' export { CodexTurnOrdinals, MAX_CODEX_TURN_ORDINAL_BYTES, MAX_CODEX_TURN_ORDINAL_ENTRIES } from './codex-turn-ordinals' -// Codex thread items → journal item bodies and durable identities. -// -// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers -// item ids positionally on resume (`item-1`…`item-N` across the whole thread), -// and a resumed turn does NOT contain every item the live turn emitted — -// reasoning and command execution are dropped from persisted history. Numbering -// by live position would therefore shift every message after the first tool -// call and hand the user a duplicate of the assistant's answer after a resume. -// -// So the ordinal counts MESSAGE items only, and the same projection is applied -// to the live stream and to a resumed turn's item list. Any other item type — -// including ones this build does not model — is skipped identically on both -// sides, which is what makes the key survive a Codex release that adds one. - -/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ -const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) - -export type CodexThreadItem = { - type: string - id: string - [key: string]: unknown -} - -export function isCodexMessageItemType(type: string): boolean { - return CODEX_MESSAGE_ITEM_TYPES.has(type) -} - -export function readCodexThreadItem(value: unknown): CodexThreadItem | null { - if (typeof value !== 'object' || value === null) { - return null - } - const record = value as Record - return typeof record.type === 'string' && typeof record.id === 'string' - ? (record as CodexThreadItem) - : null -} - -function readRecord(value: unknown): Record { - return typeof value === 'object' && value !== null ? (value as Record) : {} -} - -/** - * Durable identity for a Codex item, or null for one that has none. - * - * Non-message items fall back to the `orca` namespace keyed by the Codex item - * id. That id is unstable across resume, so those rows are live-session detail - * that a recovered journal simply will not contain — which is correct: Codex - * itself does not persist them either. - */ -export function codexItemIdentity(input: { - threadId: string - turnId: string | null - item: CodexThreadItem - ordinals: CodexTurnOrdinals -}): AgentJournalItemIdentity { - const { item, turnId } = input - if (turnId && isCodexMessageItemType(item.type)) { - return { - provider: 'codex', - threadId: input.threadId, - turnId, - ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id) - } - } - return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` } -} - -function readString(source: Record, key: string): string | null { - const value = source[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -function readFirstString(source: Record, keys: readonly string[]): string | null { - for (const key of keys) { - const value = readString(source, key) - if (value !== null) { - return value - } - } - return null -} - -function readTextContent(source: Record, key: string): string | null { - const direct = readString(source, key) - if (direct) { - return direct - } - const value = source[key] - if (!Array.isArray(value)) { - return null - } - const parts = value.flatMap((part) => { - if (typeof part === 'string') { - return part.length > 0 ? [part] : [] - } - if (typeof part !== 'object' || part === null) { - return [] - } - const text = readString(part as Record, 'text') - return text ? [text] : [] - }) - return parts.length > 0 ? parts.join('\n') : null -} +// Codex thread items → journal item bodies. /** `userMessage` carries structured content parts; `agentMessage` a flat text. */ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { @@ -178,12 +86,14 @@ export type CodexJournalItem = { function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + const parsed = commandActionFacts(item) return { body: { kind: 'tool-call', - name: 'shell', + name: parsed?.name ?? 'shell', + // Raw command and cwd stay so the expanded view still shows what ran. input: boundToolInput( - { command: item.command ?? null, cwd: item.cwd ?? null }, + { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, DEFAULT_JOURNAL_PAYLOAD_LIMITS ), state: commandState(item), @@ -225,6 +135,85 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { } } +/** The tool name reaches the row verbatim — downstream dispatch (diff renderer, + * question parsers, input previews) matches raw identifiers, so any casing + * transform would silently miss them. `server/` qualifies it so two servers + * exposing the same tool stay distinguishable and neither shadows a built-in. */ +function mcpToolCallName(item: CodexThreadItem): string { + const tool = readString(item, 'tool') + const server = readString(item, 'server') + return tool === null ? 'mcp' : server === null ? tool : `${server}/${tool}` +} + +/** Row-label derivation only reads top-level keys, so the call's own arguments + * have to be the input itself. `arguments` is arbitrary JSON upstream: a + * non-object stays addressable under a key rather than being dropped, while a + * no-argument call — `{}` on the wire, the shape every argument-less MCP tool + * sends — becomes null so the row reads as a bare `server/tool` instead of a + * literal `{}`. */ +function mcpToolArguments(value: unknown): unknown { + if (typeof value !== 'object' || value === null) { + return value === null || value === undefined ? null : { arguments: value } + } + return Array.isArray(value) ? { arguments: value } : Object.keys(value).length > 0 ? value : null +} + +function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { + const failure = readString(readRecord(item.error), 'message') + const text = failure ?? readTextContent(readRecord(item.result), 'content') + const bounded = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + body: { + kind: 'tool-call', + name: mcpToolCallName(item), + input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state: failure === null ? commandState(item) : 'failed', + ...(bounded === null ? {} : { output: bounded.bounded }) + }, + handled: true + } +} + +/** A row label is read off top-level keys only, so the action's own labelable + * fields are hoisted beside the query while `action` stays whole for the + * expanded detail. The action `type` lands on `description`, the lowest-ranked + * label key, so it names only an action that carries nothing better. */ +function webSearchInput(item: CodexThreadItem): Record | null { + const action = readRecord(item.action) + const fields: [string, unknown][] = [ + ['url', readString(action, 'url')], + ['pattern', readString(action, 'pattern')], + ['description', readString(action, 'type')], + ['action', item.action ?? null] + ] + const query = readString(item, 'query') ?? readString(action, 'query') + const present = fields.filter(([, value]) => value !== null) + // A blank `query` is the run header's "this call has no brief argument" + // signal; drop the key and the header stands the row's raw JSON in for one. + return query === null && present.length === 0 + ? null + : { query: query ?? '', ...Object.fromEntries(present) } +} + +/** `webSearch` carries no status: Codex starts it with an empty query and a null + * action, then sends the action, so `action` is the completion signal — a + * completed item's own `query` is routinely still empty. The hits arrive on + * `results` and are the call's output. */ +function webSearchItem(item: CodexThreadItem): CodexJournalItem { + const hits = Array.isArray(item.results) && item.results.length > 0 ? item.results : null + const bounded = hits && boundInlineText(JSON.stringify(hits), DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + body: { + kind: 'tool-call', + name: 'web_search', + input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state: item.action === null || item.action === undefined ? 'running' : 'completed', + ...(bounded === null ? {} : { output: bounded.bounded }) + }, + handled: true + } +} + /** * Journal body for a Codex item, or null for one with nothing to render. * @@ -248,6 +237,12 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { if (item.type === 'fileChange') { return fileChangeItem(item) } + if (item.type === 'mcpToolCall') { + return mcpToolCallItem(item) + } + if (item.type === 'webSearch') { + return webSearchItem(item) + } if (item.type === 'reasoning' || item.type === 'plan') { const text = readTextContent(item, 'text') ?? diff --git a/src/main/codex/codex-thread-item-identity.ts b/src/main/codex/codex-thread-item-identity.ts new file mode 100644 index 00000000000..0488e5c00e9 --- /dev/null +++ b/src/main/codex/codex-thread-item-identity.ts @@ -0,0 +1,65 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { CodexTurnOrdinals } from './codex-turn-ordinals' + +// Codex thread items → durable journal identities. +// +// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers +// item ids positionally on resume (`item-1`…`item-N` across the whole thread), +// and a resumed turn does NOT contain every item the live turn emitted — +// reasoning and command execution are dropped from persisted history. Numbering +// by live position would therefore shift every message after the first tool +// call and hand the user a duplicate of the assistant's answer after a resume. +// +// So the ordinal counts MESSAGE items only, and the same projection is applied +// to the live stream and to a resumed turn's item list. Any other item type — +// including ones this build does not model — is skipped identically on both +// sides, which is what makes the key survive a Codex release that adds one. + +/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ +const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) + +export type CodexThreadItem = { + type: string + id: string + [key: string]: unknown +} + +export function isCodexMessageItemType(type: string): boolean { + return CODEX_MESSAGE_ITEM_TYPES.has(type) +} + +export function readCodexThreadItem(value: unknown): CodexThreadItem | null { + if (typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + return typeof record.type === 'string' && typeof record.id === 'string' + ? (record as CodexThreadItem) + : null +} + +/** + * Durable identity for a Codex item, or null for one that has none. + * + * Non-message items fall back to the `orca` namespace keyed by the Codex item + * id. That id is unstable across resume, so those rows are live-session detail + * that a recovered journal simply will not contain — which is correct: Codex + * itself does not persist them either. + */ +export function codexItemIdentity(input: { + threadId: string + turnId: string | null + item: CodexThreadItem + ordinals: CodexTurnOrdinals +}): AgentJournalItemIdentity { + const { item, turnId } = input + if (turnId && isCodexMessageItemType(item.type)) { + return { + provider: 'codex', + threadId: input.threadId, + turnId, + ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id) + } + } + return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` } +} diff --git a/src/main/computer/computer-sidecar-diagnostics.test.ts b/src/main/computer/computer-sidecar-diagnostics.test.ts new file mode 100644 index 00000000000..06d1b1cfa02 --- /dev/null +++ b/src/main/computer/computer-sidecar-diagnostics.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isComputerSidecarDiagnostic, + reportComputerDiagnostic +} from './computer-sidecar-diagnostics' + +describe('computer sidecar diagnostics', () => { + const originalSend = process.send + + afterEach(() => { + process.send = originalSend + vi.restoreAllMocks() + }) + + it('sends over IPC when running inside the sidecar', () => { + const send = vi.fn((_message: unknown) => true) + process.send = send as unknown as typeof process.send + const console_ = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + reportComputerDiagnostic('fell back to Bypass') + + // The sidecar's stdout is piped and never read, so this must not go there. + expect(console_).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledWith({ + kind: 'computer-sidecar-diagnostic', + message: 'fell back to Bypass' + }) + expect(isComputerSidecarDiagnostic(send.mock.calls[0][0])).toBe(true) + }) + + it('logs directly when there is no IPC channel', () => { + process.send = undefined + const console_ = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + reportComputerDiagnostic('fell back to Bypass') + + expect(console_).toHaveBeenCalledWith('[computer-use] fell back to Bypass') + }) + + it('does not mistake a sidecar response for a diagnostic', () => { + expect(isComputerSidecarDiagnostic({ id: 1, ok: true, result: {} })).toBe(false) + expect(isComputerSidecarDiagnostic({ kind: 'computer-sidecar-diagnostic' })).toBe(false) + expect(isComputerSidecarDiagnostic(null)).toBe(false) + }) +}) diff --git a/src/main/computer/computer-sidecar-diagnostics.ts b/src/main/computer/computer-sidecar-diagnostics.ts new file mode 100644 index 00000000000..2b23418940d --- /dev/null +++ b/src/main/computer/computer-sidecar-diagnostics.ts @@ -0,0 +1,38 @@ +/** + * Warnings from the computer-use provider, routed to somewhere a human sees. + * + * Why not `console.warn`: the provider runs inside the forked sidecar, which + * `sidecar-client.ts` starts with piped stdio that nothing ever reads. Anything + * written there is discarded — including the only signal that a machine has + * fallen back to `-ExecutionPolicy Bypass`, a state that persists for the + * session. The sidecar has an IPC channel already, so the warning takes it. + */ +export type ComputerSidecarDiagnostic = { + kind: 'computer-sidecar-diagnostic' + message: string +} + +const DIAGNOSTIC_KIND = 'computer-sidecar-diagnostic' + +export function isComputerSidecarDiagnostic( + message: unknown +): message is ComputerSidecarDiagnostic { + if (!message || typeof message !== 'object') { + return false + } + const record = message as Record + return record.kind === DIAGNOSTIC_KIND && typeof record.message === 'string' +} + +export function reportComputerDiagnostic(message: string): void { + if (process.send) { + process.send({ kind: DIAGNOSTIC_KIND, message } satisfies ComputerSidecarDiagnostic) + return + } + logComputerDiagnostic(message) +} + +/** The main-process end: how a sidecar's forwarded diagnostic is printed. */ +export function logComputerDiagnostic(message: string): void { + console.warn(`[computer-use] ${message}`) +} diff --git a/src/main/computer/desktop-script-action.ts b/src/main/computer/desktop-script-action.ts index 38dde4b56fa..7c2c21f1e8c 100644 --- a/src/main/computer/desktop-script-action.ts +++ b/src/main/computer/desktop-script-action.ts @@ -228,3 +228,17 @@ export function elementParam( } return element } + +/** + * Tools that only observe, and so may be safely re-sent to a fresh helper. + * + * Why an allowlist: a helper can die after running an operation but before + * writing its reply, so a replayed mutation is a second click, keystroke or + * paste. Only the observation tools are provably safe to repeat, and a tool + * added later has to opt in rather than inherit a replay by default. + */ +const OBSERVATION_TOOLS = new Set(['handshake', 'list_apps', 'list_windows', 'get_app_state']) + +export function isReplayableTool(tool: string): boolean { + return OBSERVATION_TOOLS.has(tool) +} diff --git a/src/main/computer/desktop-script-provider-bridge.ts b/src/main/computer/desktop-script-provider-bridge.ts index c3c21496d29..fecc4036df1 100644 --- a/src/main/computer/desktop-script-provider-bridge.ts +++ b/src/main/computer/desktop-script-provider-bridge.ts @@ -1,28 +1,98 @@ import { execFile } from 'node:child_process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { reportComputerDiagnostic } from './computer-sidecar-diagnostics' import { RuntimeClientError } from './runtime-client-error' import type { DesktopScriptPlatform } from './desktop-script-provider-paths' +import { + FALLBACK_WINDOWS_EXECUTION_POLICY, + PREFERRED_WINDOWS_EXECUTION_POLICY, + isExecutionPolicyBlocked, + windowsPowerShellRuntimeArgs +} from './windows-powershell-execution-policy' const REQUEST_TIMEOUT_MS = 30_000 const FORCE_KILL_GRACE_MS = 1_000 -export function execBridge( +export async function execBridge( platform: DesktopScriptPlatform, scriptPath: string, operationPath: string ): Promise<{ stdout: string; stderr: string }> { - const command = platform === 'windows' ? 'powershell.exe' : 'python3' - const args = - platform === 'windows' - ? [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-File', - scriptPath, - operationPath - ] - : [scriptPath, operationPath] + if (platform !== 'windows') { + return await mapped(runBridgeProcess('python3', [scriptPath, operationPath])) + } + const command = windowsPowerShellPath() + try { + return await runBridgeProcess( + command, + windowsPowerShellRuntimeArgs(scriptPath, PREFERRED_WINDOWS_EXECUTION_POLICY, [operationPath]) + ) + } catch (error) { + if (!isPolicyBlockedStart(error)) { + throw error instanceof BridgeProcessFailure ? error.mapped : error + } + reportComputerDiagnostic( + `bridge start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; retrying once with ${FALLBACK_WINDOWS_EXECUTION_POLICY}` + ) + return await mapped( + runBridgeProcess( + command, + windowsPowerShellRuntimeArgs(scriptPath, FALLBACK_WINDOWS_EXECUTION_POLICY, [operationPath]) + ) + ) + } +} + +/** Unwrap the raw-stream carrier back into the error callers expect. */ +async function mapped( + run: Promise<{ stdout: string; stderr: string }> +): Promise<{ stdout: string; stderr: string }> { + try { + return await run + } catch (error) { + throw error instanceof BridgeProcessFailure ? error.mapped : error + } +} + +/** + * Only a run that produced no stdout at all may be replayed. + * + * What the stdout guard covers: operations are not idempotent, and the response + * embeds window titles and element names, so a snapshot that merely contains + * the word "SecurityError" must not be read as a policy block and replayed as a + * second click, keystroke or paste. It closes that injection route only. + * + * What it does not cover: one-shot mode runs the operation to completion and + * writes stdout only afterwards, so stdout is empty for the whole action, not + * just before it starts. A crash after the click but before the write looks + * identical to a helper that never started. Nothing here can tell those apart — + * only a policy pattern that cannot match a non-policy failure keeps the replay + * off, which is why its `\b` is load-bearing rather than cosmetic. + */ +function isPolicyBlockedStart(error: unknown): error is BridgeProcessFailure { + return ( + error instanceof BridgeProcessFailure && + !error.stdout.trim() && + isExecutionPolicyBlocked(error.stderr) + ) +} + +/** Carries the raw streams so the retry decision does not read a mapped message. */ +class BridgeProcessFailure extends Error { + constructor( + readonly stdout: string, + readonly stderr: string, + readonly mapped: RuntimeClientError + ) { + super(mapped.message) + this.name = 'BridgeProcessFailure' + } +} + +function runBridgeProcess( + command: string, + args: readonly string[] +): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { let child: ReturnType | null = null let settled = false @@ -76,7 +146,7 @@ export function execBridge( try { child = execFile( command, - args, + [...args], { env: process.env, maxBuffer: 20 * 1024 * 1024, @@ -86,11 +156,10 @@ export function execBridge( (error, stdout, stderr) => { if (error) { const message = stderr.trim() || stdout.trim() || error.message - finish( - error.killed - ? new RuntimeClientError('action_timeout', message) - : mapBridgeError(message) - ) + const mapped = error.killed + ? new RuntimeClientError('action_timeout', message) + : mapBridgeError(message) + finish(new BridgeProcessFailure(stdout, stderr, mapped)) return } finish(null, { stdout, stderr }) diff --git a/src/main/computer/desktop-script-provider-client.ts b/src/main/computer/desktop-script-provider-client.ts index 5e8e01e0d44..a655eeddb07 100644 --- a/src/main/computer/desktop-script-provider-client.ts +++ b/src/main/computer/desktop-script-provider-client.ts @@ -35,6 +35,7 @@ import type { BridgeResponse, NativeActionMethod } from './desktop-script-provider-types' +import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host' import { DesktopScriptSnapshotStore } from './desktop-script-snapshot-store' import { normalizeBridgeApp, renderSnapshot } from './desktop-script-snapshot-rendering' import { normalizeComputerActionResult } from './computer-action-verification-normalization' @@ -51,12 +52,17 @@ export class DesktopScriptProviderClient { constructor( private readonly platform: DesktopScriptPlatform = requiredPlatform(), - private readonly scriptPath: string = requiredScriptPath() + private readonly scriptPath: string = requiredScriptPath(), + private readonly runtimeHost: DesktopScriptRuntimeHost | null = defaultRuntimeHost( + platform, + scriptPath + ) ) {} shutdown(): void { this.snapshotStore.clear() this.providerCapabilities = null + this.runtimeHost?.dispose() } async listApps(): Promise { @@ -203,6 +209,23 @@ export class DesktopScriptProviderClient { } private async callBridge(request: BridgeRequest): Promise { + const host = this.runtimeHost + if (host) { + try { + return checkedBridgeResponse(await host.request(request), '') + } catch (error) { + // Only a helper that cannot start falls back; operation errors surface. + // The host is kept: it re-probes after its cooldown, so a transient bad + // spawn cannot strand the session on one powershell.exe per operation. + if (!isRuntimeHostUnavailable(error)) { + throw error + } + } + } + return await this.callOneShotBridge(request) + } + + private async callOneShotBridge(request: BridgeRequest): Promise { const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-')) const operationPath = join(operationDirectory, 'operation.json') try { @@ -217,10 +240,7 @@ export class DesktopScriptProviderClient { `desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}` ) } - if (!response.ok) { - throw mapBridgeError(response.error ?? stderr) - } - return response + return checkedBridgeResponse(response, stderr) } finally { await rm(operationDirectory, { force: true, recursive: true }) } @@ -255,6 +275,21 @@ export class DesktopScriptProviderClient { } } +function checkedBridgeResponse(response: BridgeResponse, stderr: string): BridgeResponse { + if (!response.ok) { + throw mapBridgeError(response.error ?? stderr) + } + return response +} + +// Why Windows only: the Linux provider is a python3 one-shot with no serve mode. +function defaultRuntimeHost( + platform: DesktopScriptPlatform, + scriptPath: string +): DesktopScriptRuntimeHost | null { + return platform === 'windows' ? new DesktopScriptRuntimeHost(scriptPath) : null +} + function requiredPlatform(): DesktopScriptPlatform { const platform = desktopScriptPlatform() if (!platform) { diff --git a/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts b/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts new file mode 100644 index 00000000000..e50a3878a11 --- /dev/null +++ b/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + bridgeProcessArgs, + createDesktopScriptProviderClient, + expectDesktopProviderSubprocessStartCount, + mockBridgeProcessFailure, + mockBridgeResponse, + resetDesktopScriptProviderTestHarness, + sampleCapabilities +} from './desktop-script-provider-test-harness' +import type { BridgeResponse } from './desktop-script-provider-types' +import type { DesktopScriptRuntimeHost } from './desktop-script-runtime-host' +import { RuntimeClientError } from './runtime-client-error' + +const POLICY_STDERR = + 'File runtime.ps1 cannot be loaded because running scripts is disabled on this system. + CategoryInfo : SecurityError' + +function fakeRuntimeHost(request: DesktopScriptRuntimeHost['request']) { + const dispose = vi.fn() + return { host: { request, dispose } as unknown as DesktopScriptRuntimeHost, dispose } +} + +describe('desktop script provider runtime host routing', () => { + afterEach(resetDesktopScriptProviderTestHarness) + + it('serves Windows operations from the runtime host without spawning a one-shot bridge', async () => { + const request = vi.fn( + async () => ({ ok: true, capabilities: sampleCapabilities() }) as BridgeResponse + ) + const { host } = fakeRuntimeHost(request) + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host) + + await expect(client.capabilities()).resolves.toMatchObject({ platform: 'linux' }) + expect(request).toHaveBeenCalledWith({ tool: 'handshake' }) + expectDesktopProviderSubprocessStartCount(0) + }) + + it('maps runtime host operation failures without falling back to the one-shot bridge', async () => { + const { host } = fakeRuntimeHost( + vi.fn(async () => ({ ok: false, error: 'appBlocked("1Password")' }) as BridgeResponse) + ) + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host) + + await expect(client.listApps()).rejects.toMatchObject({ code: 'app_blocked' }) + expectDesktopProviderSubprocessStartCount(0) + }) + + it('degrades to the one-shot bridge for the operations a host cannot serve', async () => { + const request = vi.fn(async () => { + throw new RuntimeClientError('runtime_host_unavailable', 'could not start') + }) + const { host, dispose } = fakeRuntimeHost(request as never) + mockBridgeResponse({ ok: true, apps: [{ name: 'Notepad', pid: 42 }] }) + mockBridgeResponse({ ok: true, apps: [{ name: 'Notepad', pid: 42 }] }) + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host) + + await expect(client.listApps()).resolves.toMatchObject({ apps: [{ pid: 42 }] }) + await client.listApps() + + // The host is kept and asked again: it owns its own cooldown, so one bad + // spawn must not stand the session down to a powershell.exe per click. + expect(request).toHaveBeenCalledTimes(2) + expect(dispose).not.toHaveBeenCalled() + expectDesktopProviderSubprocessStartCount(2) + }) + + it('returns to the runtime host once it recovers', async () => { + let healthy = false + const request = vi.fn(async () => { + if (!healthy) { + throw new RuntimeClientError('runtime_host_unavailable', 'could not start') + } + return { ok: true, apps: [] } as BridgeResponse + }) + const { host } = fakeRuntimeHost(request) + mockBridgeResponse({ ok: true, apps: [] }) + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host) + + await client.listApps() + expectDesktopProviderSubprocessStartCount(1) + + healthy = true + await expect(client.listApps()).resolves.toEqual({ apps: [] }) + expectDesktopProviderSubprocessStartCount(1) + }) + + it('runs the one-shot bridge under RemoteSigned and falls back to Bypass once', async () => { + mockBridgeProcessFailure(POLICY_STDERR) + mockBridgeResponse({ ok: true, apps: [] }) + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1') + + await expect(client.listApps()).resolves.toEqual({ apps: [] }) + expectDesktopProviderSubprocessStartCount(2) + expect(bridgeProcessArgs(0)).toContain('-NoLogo') + expect(bridgeProcessArgs(0)).toContain('RemoteSigned') + expect(bridgeProcessArgs(0)).not.toContain('Bypass') + expect(bridgeProcessArgs(1)).toContain('Bypass') + }) + + it('does not retry the one-shot bridge for a non-policy failure', async () => { + mockBridgeProcessFailure('No top-level UI Automation window is available for Notepad') + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1') + + await expect(client.listApps()).rejects.toMatchObject({ code: 'window_not_found' }) + expectDesktopProviderSubprocessStartCount(1) + }) + + it('never replays an operation whose own output merely mentions a policy error', async () => { + // Window titles and element names are user-controlled text that lands in + // stdout; matching them would double a click, a keystroke or a paste. + mockBridgeProcessFailure({ + stdout: JSON.stringify({ + ok: true, + snapshot: { windowTitle: 'SecurityError - UnauthorizedAccess.log - Notepad' } + }), + stderr: '' + }) + + const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1') + + await expect(client.listApps()).rejects.toBeInstanceOf(Error) + expectDesktopProviderSubprocessStartCount(1) + }) + + it('keeps Linux on the one-shot python bridge with no execution policy flags', async () => { + mockBridgeResponse({ ok: true, apps: [] }) + + const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py') + + await expect(client.listApps()).resolves.toEqual({ apps: [] }) + expect(bridgeProcessArgs(0)).toEqual(['/tmp/runtime.py', expect.any(String)]) + }) +}) diff --git a/src/main/computer/desktop-script-provider-test-harness.ts b/src/main/computer/desktop-script-provider-test-harness.ts index 2cbd1e9a776..bcb0a4b0118 100644 --- a/src/main/computer/desktop-script-provider-test-harness.ts +++ b/src/main/computer/desktop-script-provider-test-harness.ts @@ -1,4 +1,5 @@ import { expect, vi } from 'vitest' +import type { DesktopScriptRuntimeHost } from './desktop-script-runtime-host' const { execFileMock, operationFiles, mkdtempMock, rmMock, writeFileMock } = vi.hoisted(() => { const files = new Map() @@ -23,12 +24,14 @@ vi.mock('fs/promises', () => ({ writeFile: writeFileMock })) +/** Builds a client on the one-shot bridge; pass a host to exercise serve mode. */ export async function createDesktopScriptProviderClient( platform: 'linux' | 'windows', - executablePath: string + executablePath: string, + runtimeHost: DesktopScriptRuntimeHost | null = null ) { const { DesktopScriptProviderClient } = await import('./desktop-script-provider-client') - return new DesktopScriptProviderClient(platform, executablePath) + return new DesktopScriptProviderClient(platform, executablePath, runtimeHost) } export function resetDesktopScriptProviderTestHarness(): void { @@ -77,6 +80,19 @@ export function mockBridgeResponse( }) } +export function mockBridgeProcessFailure(streams: string | { stdout?: string; stderr?: string }) { + const { stdout = '', stderr = '' } = typeof streams === 'string' ? { stderr: streams } : streams + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + const done = callback as (error: Error | null, stdout: string, stderr: string) => void + done(new Error('Command failed'), stdout, stderr) + return null as never + }) +} + +export function bridgeProcessArgs(call: number): string[] { + return (execFileMock.mock.calls[call]?.[1] ?? []) as string[] +} + export function sampleBridgeSnapshot(name: string, value: string) { return { app: { name, bundleIdentifier: name, pid: 100 }, diff --git a/src/main/computer/desktop-script-provider-types.ts b/src/main/computer/desktop-script-provider-types.ts index 0ff48e80b4f..6e474fdcf29 100644 --- a/src/main/computer/desktop-script-provider-types.ts +++ b/src/main/computer/desktop-script-provider-types.ts @@ -101,6 +101,8 @@ export type BridgeWindow = { export type BridgeResponse = { ok: boolean + /** Echo of BridgeRequest.requestId; set only on the persistent serve path. */ + requestId?: number error?: string capabilities?: ComputerProviderCapabilities apps?: { @@ -122,6 +124,8 @@ export type BridgeResponse = { export type BridgeRequest = { tool: string + /** Correlates a serve-mode reply with its request; the one-shot path omits it. */ + requestId?: number app?: string element?: BridgeElement fromElement?: BridgeElement diff --git a/src/main/computer/desktop-script-request-queue.ts b/src/main/computer/desktop-script-request-queue.ts new file mode 100644 index 00000000000..8f32b5f6888 --- /dev/null +++ b/src/main/computer/desktop-script-request-queue.ts @@ -0,0 +1,73 @@ +import { RuntimeClientError } from './runtime-client-error' + +/** + * Serializes operations onto one helper and bounds how long one may wait its + * turn. + * + * Why the wait needs its own deadline: the in-flight timeout is armed only once + * a request reaches a helper, so a request behind N timing-out ones waited N + * times that timeout with no deadline of its own — bounded, but the caller sees + * an `await` that looks hung for minutes and gets no error to act on. + * + * Why only the wait: a request that reaches a helper still gets its full + * execution budget. A single deadline covering both would fail operations that + * queued briefly and would otherwise have succeeded. + */ +export class DesktopScriptRequestQueue { + /** + * Never rejects: downstream turns chain onto it, and a rejection here would + * be delivered to whichever request happened to queue behind the failure. + */ + private tail: Promise | null = null + + constructor( + private readonly waitTimeoutMs: number, + /** Called when the queue empties, so the host can arm its idle shutdown. */ + private readonly onDrained: () => void + ) {} + + enqueue(run: () => Promise): Promise { + const queued = this.tail + if (!queued) { + return this.track(run()) + } + let expiry: RuntimeClientError | null = null + let waitTimer: NodeJS.Timeout | undefined + const waited = new Promise((_resolve, reject) => { + waitTimer = setTimeout(() => { + expiry = new RuntimeClientError( + 'action_timeout', + `desktop provider timed out after ${this.waitTimeoutMs}ms waiting for earlier operations` + ) + reject(expiry) + }, this.waitTimeoutMs) + waitTimer.unref?.() + }) + // An abandoned request is never handed to a helper. The caller has already + // been told it failed, and a click delivered after that is worse than none. + const turn = (): Promise => { + clearTimeout(waitTimer) + return expiry ? Promise.reject(expiry) : run() + } + // The tail chains on the turn, not on the race: a caller giving up early + // must not release the next request while this one's predecessor is still + // in flight. + return Promise.race([waited, this.track(queued.then(turn, turn))]) + } + + private track(result: Promise): Promise { + const tail = result.then( + () => undefined, + () => undefined + ) + this.tail = tail + void tail.finally(() => { + if (this.tail !== tail) { + return + } + this.tail = null + this.onDrained() + }) + return result + } +} diff --git a/src/main/computer/desktop-script-runtime-availability.ts b/src/main/computer/desktop-script-runtime-availability.ts new file mode 100644 index 00000000000..58123f5eab7 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-availability.ts @@ -0,0 +1,176 @@ +import { + FALLBACK_WINDOWS_EXECUTION_POLICY, + PREFERRED_WINDOWS_EXECUTION_POLICY, + type WindowsExecutionPolicy +} from './windows-powershell-execution-policy' + +/** + * Consecutive child failures before the helper is believed dead, and how long + * the one-shot bridge covers for it afterwards. + * + * Why not a latch: every plausible cause is transient — a Defender scan touching + * the script mid-launch, a locked CSC temp directory failing one `Add-Type`, + * momentary memory pressure. Giving up permanently silently restores the + * per-click process burst the host exists to remove, and computer use keeps + * working throughout, so nothing looks wrong while the MDE signature returns. + */ +export const MAX_START_ATTEMPTS = 3 +export const START_FAILURE_COOLDOWN_MS = 60_000 + +/** + * Why not `Date.now`: an NTP correction, a VM snapshot restore or a user changing + * the clock steps the wall clock backwards, which extended the cooldown by the + * size of the step. Nothing shortens it from there — only `recordSuccess` clears + * it, and no request can reach a helper to succeed while it holds — so a one-hour + * step disabled the persistent helper for the life of the sidecar, silently + * restoring the per-click process burst. Elapsed monotonic time cannot go + * backwards. + */ +const monotonicNowMs = (): number => performance.now() + +/** + * Whether the persistent helper is currently believed usable, and the execution + * policy it should be started under. + * + * Split from the host so the recovery rules are readable on their own: they are + * what stands between a transient bad spawn and a session that silently spends + * the rest of its life on one powershell.exe per click. + */ +export class RuntimeHostAvailability { + private policy: WindowsExecutionPolicy = PREFERRED_WINDOWS_EXECUTION_POLICY + private retryUnderFallbackPolicy = false + private consecutiveFailures = 0 + private consecutiveSuccesses = 0 + /** Null, not 0, for "no cooldown": `performance.now()` legitimately returns 0. */ + private cooldownStartedAtMs: number | null = null + /** + * Set while the escalated policy has yet to start a helper, so a wrong + * diagnosis can be taken back. + * + * Why it can be wrong: AppLocker and WDAC constrained language mode raise + * PSSecurityException under the same SecurityError category a policy block + * uses, but they refuse the script at parse time, which `Bypass` cannot lift. + * Latching there would spend the session putting the most heavily weighted + * MDE token on every command line, on exactly the hardened hosts watching + * for it. + */ + private fallbackPolicyUnproven = false + + constructor( + private readonly cooldownMs: number, + /** Public so the host can report its own start attempts to the same sink. */ + readonly warn: (message: string) => void, + /** Overridden only by tests; the default must stay monotonic. */ + private readonly now: () => number = monotonicNowMs + ) {} + + get executionPolicy(): WindowsExecutionPolicy { + return this.policy + } + + get policyRetryPending(): boolean { + return this.retryUnderFallbackPolicy + } + + get atPreferredPolicy(): boolean { + return this.policy === PREFERRED_WINDOWS_EXECUTION_POLICY + } + + /** Milliseconds left before the host may try a helper again; 0 when it may. */ + remainingCooldown(): number { + if (this.cooldownStartedAtMs === null) { + return 0 + } + // Elapsed since the cooldown began, never a stored deadline: a deadline is + // only as trustworthy as the clock it was computed against. + return Math.max(0, Math.ceil(this.cooldownMs - (this.now() - this.cooldownStartedAtMs))) + } + + requestPolicyRetry(): void { + this.retryUnderFallbackPolicy = true + } + + escalateExecutionPolicy(): void { + this.retryUnderFallbackPolicy = false + this.policy = FALLBACK_WINDOWS_EXECUTION_POLICY + this.fallbackPolicyUnproven = true + // Sticky once proven: a genuinely Restricted machine would otherwise pay a + // guaranteed failed spawn per operation. Only a helper that produced no + // output at all can reach here, so a snapshot cannot talk the host into it. + this.warn( + `runtime host start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; trying ${FALLBACK_WINDOWS_EXECUTION_POLICY}` + ) + } + + /** A helper started under the current policy, so the policy is the right one. */ + confirmExecutionPolicy(): void { + this.fallbackPolicyUnproven = false + } + + /** + * Undo an escalation the fallback never justified. + * + * The escalation is a diagnosis, and a fallback that cannot start a helper + * either disproves it: the policy was not what stopped the first attempt. Go + * back rather than latch, so a re-probe can escalate again later if the real + * cause clears. Re-probing costs one spawn per outage, which the failure + * count and its cooldown already bound, and never latching is the whole point + * of this class. + */ + abandonUnprovenFallback(): void { + if (!this.fallbackPolicyUnproven) { + return + } + this.fallbackPolicyUnproven = false + this.policy = PREFERRED_WINDOWS_EXECUTION_POLICY + this.warn( + `${FALLBACK_WINDOWS_EXECUTION_POLICY} did not start a helper either, so the execution policy was not the cause; returning to ${PREFERRED_WINDOWS_EXECUTION_POLICY}` + ) + } + + recordFailure(): void { + this.consecutiveSuccesses = 0 + this.consecutiveFailures++ + } + + /** True once a helper has died often enough that respawning is just thrash. */ + get exhausted(): boolean { + return this.consecutiveFailures >= MAX_START_ATTEMPTS + } + + recordSuccess(): void { + this.consecutiveSuccesses++ + this.fallbackPolicyUnproven = false + // Why a clean run and not a single reply: a helper that answers one + // operation and dies on the next would otherwise reset the count forever, + // and respawn once per operation — the exact burst the host removes. + if (this.consecutiveSuccesses >= MAX_START_ATTEMPTS) { + this.consecutiveFailures = 0 + } + if (this.cooldownStartedAtMs === null) { + return + } + this.cooldownStartedAtMs = null + this.warn('runtime host recovered; operations are served by the persistent helper again') + } + + enterCooldown(): void { + // An escalation that never started a helper must not outlive the outage it + // was guessed from; the next one re-diagnoses from the preferred policy. + this.abandonUnprovenFallback() + const failures = this.consecutiveFailures + this.cooldownStartedAtMs = this.now() + // The wait is the penalty; leaving the count at the limit would charge twice + // and let the first death after recovery re-enter a full cooldown, so an + // interleaved workload would spend its life on the one-shot bridge. + this.consecutiveFailures = 0 + this.consecutiveSuccesses = 0 + this.warn( + `runtime host unavailable after ${failures} consecutive failures; falling back to one powershell.exe per operation for ${this.cooldownMs}ms` + ) + } + + clearCooldown(): void { + this.cooldownStartedAtMs = null + } +} diff --git a/src/main/computer/desktop-script-runtime-host.test.ts b/src/main/computer/desktop-script-runtime-host.test.ts new file mode 100644 index 00000000000..4d42a3c3e37 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.test.ts @@ -0,0 +1,857 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type { RuntimeChildProcess } from './desktop-script-serve-channel' +import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host' + +const POLICY_ERROR = + 'File runtime.ps1 cannot be loaded because running scripts\nis disabled on this system.\n + CategoryInfo : SecurityError' + +class FakeRuntimeChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + readonly writes: string[] = [] + killed = false + stdinEnded = false + /** Holds write callbacks so a late stdin failure can be fired deliberately. */ + deferWrites = false + private readonly pendingWrites: ((error?: Error | null) => void)[] = [] + + readonly stdin = { + write: (chunk: string, callback?: (error?: Error | null) => void): boolean => { + this.writes.push(chunk) + if (this.deferWrites) { + if (callback) { + this.pendingWrites.push(callback) + } + return true + } + callback?.(null) + return true + }, + end: (): void => { + this.stdinEnded = true + }, + on: (): void => {} + } + + kill(): boolean { + this.killed = true + return true + } + + /** What a destroyed stdin does to writes still queued at teardown. */ + failQueuedWrites(): void { + for (const callback of this.pendingWrites.splice(0)) { + callback(new Error('ERR_STREAM_DESTROYED')) + } + } + + /** Fail one queued write, leaving later ones outstanding. */ + failQueuedWrite(index: number): void { + this.pendingWrites.splice(index, 1)[0](new Error('EPIPE')) + } + + /** Requests written to this child, decoded. */ + requests(): Record[] { + return this.writes.map((line) => JSON.parse(line) as Record) + } + + /** The id the host is currently waiting on, so replies can echo it. */ + pendingId(): number { + return this.requests().at(-1)?.requestId as number + } + + /** The announcement the real serve loop writes before its first read. */ + ready(): void { + this.write('{"ready":true}\n') + } + + respond(response: Record, requestId = this.pendingId()): void { + this.write(`${JSON.stringify({ ...response, requestId })}\n`) + } + + write(raw: string): void { + this.stdout.emit('data', Buffer.from(raw, 'utf8')) + } + + exit(code: number | null, stderr = ''): void { + if (stderr) { + this.stderr.emit('data', Buffer.from(stderr, 'utf8')) + } + this.emit('close', code, null) + } +} + +function createHost( + options: { + idleShutdownMs?: number + requestTimeoutMs?: number + cooldownMs?: number + now?: () => number + deferWrites?: boolean + } = {} +) { + const children: FakeRuntimeChild[] = [] + const specs: ProcessSpec[] = [] + const warnings: string[] = [] + const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', { + ...options, + powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe', + warn: (message) => warnings.push(message), + spawn: (spec) => { + specs.push(spec) + const child = new FakeRuntimeChild() + child.deferWrites = options.deferWrites === true + children.push(child) + return child as unknown as RuntimeChildProcess + } + }) + return { host, children, specs, warnings } +} + +/** Let the host's queue microtasks drain so the next request reaches its child. */ +async function settle(): Promise { + for (let index = 0; index < 6; index++) { + await Promise.resolve() + } +} + +/** The wait the host reported, read back out of its refusal message. */ +function remainingCooldownMs(error: Error | null): number { + const match = /retrying the runtime host in (\d+)ms/.exec(error?.message ?? '') + return match ? Number(match[1]) : Number.NaN +} + +/** Kill each helper the host starts, until it stops starting them. */ +async function failEveryStart(children: FakeRuntimeChild[], stderr: string): Promise { + for (let index = 0; index < 8; index++) { + if (index >= children.length) { + return + } + children[index].exit(1, stderr) + await settle() + } +} + +describe('DesktopScriptRuntimeHost', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('starts one helper for many operations and never writes an operation file', async () => { + const { host, children, specs } = createHost() + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await expect(first).resolves.toMatchObject({ ok: true }) + + for (let index = 0; index < 5; index++) { + const next = host.request({ tool: 'click', app: 'Notepad' }) + await settle() + children[0].respond({ ok: true, action: { path: 'synthetic' } }) + await expect(next).resolves.toMatchObject({ ok: true }) + } + + expect(children).toHaveLength(1) + expect(children[0].requests()).toHaveLength(6) + expect(specs[0].args).toEqual([ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'RemoteSigned', + '-File', + 'C:\\orca\\runtime.ps1', + '-Serve' + ]) + host.dispose() + }) + + it('serializes requests so only one operation is ever in flight', async () => { + const { host, children } = createHost() + + const first = host.request({ tool: 'click', app: 'A' }) + const second = host.request({ tool: 'click', app: 'B' }) + await settle() + + expect(children[0].requests()).toEqual([{ tool: 'click', app: 'A', requestId: 1 }]) + + children[0].respond({ ok: true, action: { path: 'synthetic' } }) + await expect(first).resolves.toMatchObject({ ok: true }) + await settle() + + expect(children[0].requests()).toHaveLength(2) + children[0].respond({ ok: true, action: { path: 'accessibility' } }) + await expect(second).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('strips the echoed id from the response it hands back', async () => { + const { host, children } = createHost() + const promise = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + + await expect(promise).resolves.toEqual({ ok: true, capabilities: {} }) + host.dispose() + }) + + it('reassembles a response split across chunks, including a split code point', async () => { + const { host, children } = createHost() + const promise = host.request({ tool: 'get_app_state', app: 'Editor' }) + await settle() + + const payload = Buffer.from( + `${JSON.stringify({ ok: true, snapshot: { app: 'né' }, requestId: 1 })}\r\n`, + 'utf8' + ) + const split = payload.indexOf(Buffer.from('é', 'utf8')) + 1 + children[0].stdout.emit('data', payload.subarray(0, split)) + children[0].stdout.emit('data', payload.subarray(split)) + + await expect(promise).resolves.toEqual({ ok: true, snapshot: { app: 'né' } }) + host.dispose() + }) + + it('kills the helper rather than answering a request with another reply', async () => { + const { host, children } = createHost() + + const first = host.request({ tool: 'handshake' }) + await settle() + // A stray line would otherwise shift every later response by one. + children[0].respond({ ok: true, capabilities: {} }, 999) + + await expect(first).rejects.toThrow(/did not match the pending request/) + expect(children[0].killed).toBe(true) + host.dispose() + }) + + it('kills the helper when an unsolicited line arrives with nothing pending', async () => { + const { host, children } = createHost() + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await first + + children[0].write(`${JSON.stringify({ ok: true, requestId: 77 })}\n`) + expect(children[0].killed).toBe(true) + host.dispose() + }) + + it('times out a wedged operation and starts a fresh helper for the next one', async () => { + vi.useFakeTimers() + const { host, children } = createHost({ requestTimeoutMs: 30_000 }) + + const promise = host.request({ tool: 'click', app: 'Frozen' }) + await settle() + await vi.advanceTimersByTimeAsync(30_001) + + await expect(promise).rejects.toMatchObject({ code: 'action_timeout' }) + expect(children[0].killed).toBe(true) + + const next = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(2) + children[1].respond({ ok: true, capabilities: {} }) + await expect(next).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('rejects the in-flight request when a working helper crashes, then restarts', async () => { + const { host, children } = createHost() + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await first + + const second = host.request({ tool: 'click', app: 'Notepad' }) + await settle() + children[0].exit(1, 'boom') + + await expect(second).rejects.toMatchObject({ code: 'accessibility_error' }) + await expect(second).rejects.toThrow(/runtime host exited/) + + const third = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(2) + children[1].respond({ ok: true, capabilities: {} }) + await expect(third).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('stops respawning a helper that dies on every second operation', async () => { + let clock = 1_000 + const { host, children } = createHost({ cooldownMs: 60_000, now: () => clock }) + + // One good answer per helper is exactly the pattern that used to respawn + // forever: the success reset the failure count before it could ever trip. + for (let round = 0; round < 3; round++) { + const good = host.request({ tool: 'handshake' }) + await settle() + children.at(-1)?.respond({ ok: true, capabilities: {} }) + await expect(good).resolves.toMatchObject({ ok: true }) + await settle() + + const crash = host.request({ tool: 'click', app: 'Crashy' }) + await settle() + children.at(-1)?.exit(1, 'boom') + await expect(crash).rejects.toThrow(/runtime host exited/) + await settle() + } + + const spawned = children.length + await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable) + expect(children).toHaveLength(spawned) + host.dispose() + }) + + it('keeps serving a healthy helper after an isolated crash', async () => { + const { host, children } = createHost({ cooldownMs: 60_000 }) + + const crashed = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await crashed + const second = host.request({ tool: 'click', app: 'Notepad' }) + await settle() + children[0].exit(1, 'boom') + await expect(second).rejects.toThrow(/runtime host exited/) + + for (let index = 0; index < 4; index++) { + const next = host.request({ tool: 'handshake' }) + await settle() + children.at(-1)?.respond({ ok: true, capabilities: {} }) + await expect(next).resolves.toMatchObject({ ok: true }) + } + + // A clean run clears the count, so one bad helper cannot degrade a good one. + expect(children).toHaveLength(2) + host.dispose() + }) + + it('stops respawning a helper that keeps answering the wrong request', async () => { + let clock = 1_000 + const { host, children } = createHost({ cooldownMs: 60_000, now: () => clock }) + + // Desync is host-detected, so it bypassed the exit handler entirely: without + // its own accounting this respawned once per operation, forever. + for (let round = 0; round < 3; round++) { + const promise = host.request({ tool: 'handshake' }) + await settle() + const child = children.at(-1) + child?.respond({ ok: true, capabilities: {} }, child.pendingId() + 500) + await expect(promise).rejects.toThrow(/did not match the pending request/) + await settle() + } + + const spawned = children.length + await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable) + expect(children).toHaveLength(spawned) + host.dispose() + }) + + it('stops respawning a helper that times out on every operation', async () => { + vi.useFakeTimers() + let clock = 1_000 + const { host, children } = createHost({ + requestTimeoutMs: 1_000, + cooldownMs: 60_000, + now: () => clock + }) + + for (let round = 0; round < 3; round++) { + const promise = host.request({ tool: 'get_app_state', app: 'Frozen' }) + await settle() + await vi.advanceTimersByTimeAsync(1_001) + await expect(promise).rejects.toMatchObject({ code: 'action_timeout' }) + await settle() + } + + const spawned = children.length + await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable) + expect(children).toHaveLength(spawned) + host.dispose() + }) + + it('never re-sends a mutation to a fresh helper after a pre-answer death', async () => { + const { host, children } = createHost() + + const promise = host.request({ tool: 'click', app: 'Notepad', x: 10, y: 10 }) + await settle() + children[0].exit(1, 'Add-Type : Cannot access the temporary directory') + + // The click may already have landed inside the helper that died; replaying + // it would click twice. An observation in the same position is retried. + await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable) + expect(children).toHaveLength(1) + host.dispose() + }) + + it('never replays a mutation once the helper announced it was reading', async () => { + const { host, children } = createHost() + + const promise = host.request({ tool: 'click', app: 'Notepad', x: 10, y: 10 }) + await settle() + children[0].ready() + // Past the announcement the click may already have been synthesized: the + // snapshot that follows it is the fault-prone part, so a missing reply + // proves nothing about whether the input landed. + children[0].exit(1, 'faulting module gdiplus.dll') + + await expect(promise).rejects.toThrow(/runtime host exited/) + expect(children).toHaveLength(1) + host.dispose() + }) + + it('replays a mutation only for a helper that died before announcing readiness', async () => { + const { host, children } = createHost() + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].ready() + children[0].respond({ ok: true, capabilities: {} }) + await first + + const crashed = host.request({ tool: 'click', app: 'Notepad', x: 1, y: 1 }) + await settle() + children[0].exit(1, 'boom') + await expect(crashed).rejects.toThrow(/runtime host exited/) + + const retried = host.request({ tool: 'click', app: 'Notepad', x: 1, y: 1 }) + await settle() + // This helper never announced, so it cannot have read the click: replaying + // is a fact rather than a guess, and the caller never sees the stumble. + children[1].exit(1, 'Add-Type : Cannot access the temporary directory') + await settle() + + expect(children).toHaveLength(3) + children[2].ready() + children[2].respond({ ok: true, action: { path: 'synthetic' } }) + await expect(retried).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('does not treat the readiness announcement as an unmatched reply', async () => { + const { host, children } = createHost() + + const promise = host.request({ tool: 'handshake' }) + await settle() + children[0].ready() + + expect(children[0].killed).toBe(false) + children[0].respond({ ok: true, capabilities: {} }) + await expect(promise).resolves.toEqual({ ok: true, capabilities: {} }) + host.dispose() + }) + + it('charges one cooldown per outage, not one per later death', async () => { + let clock = 1_000 + const { host, children } = createHost({ cooldownMs: 60_000, now: () => clock }) + + const failed = host.request({ tool: 'handshake' }) + await settle() + await failEveryStart(children, 'The term is not recognized') + await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable) + + clock += 61_000 + const recovered = host.request({ tool: 'handshake' }) + await settle() + children.at(-1)?.respond({ ok: true, capabilities: {} }) + await recovered + + // One death after recovery must not re-enter a full cooldown; the previous + // outage was already paid for. + const crashed = host.request({ tool: 'handshake' }) + await settle() + children.at(-1)?.exit(1, 'boom') + await expect(crashed).rejects.toBeInstanceOf(Error) + + const next = host.request({ tool: 'handshake' }) + await settle() + children.at(-1)?.respond({ ok: true, capabilities: {} }) + await expect(next).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('charges one failure when a write fails after the helper was torn down', async () => { + const { host, children, warnings } = createHost({ deferWrites: true }) + + const promise = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }, 999) + await expect(promise).rejects.toThrow(/did not match the pending request/) + + // stop() destroys stdin, so the queued write calls back with an error. That + // is the same operation failing, not a second one, and counting it twice + // would drive a 3-strike cooldown at half the intended rate. + children[0].failQueuedWrites() + + expect(warnings.filter((line) => /helper stopped/.test(line))).toHaveLength(1) + host.dispose() + }) + + it('never lets a stale write error stop a replacement helper', async () => { + const { host, children } = createHost({ deferWrites: true }) + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }, 999) + await expect(first).rejects.toBeInstanceOf(Error) + + const second = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(2) + + // The late callback belongs to a channel and a request that are both gone. + children[0].failQueuedWrites() + + expect(children[1].killed).toBe(false) + children[1].respond({ ok: true, capabilities: {} }) + await expect(second).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('ignores a write error for a request that already finished', async () => { + const { host, children } = createHost({ deferWrites: true }) + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await first + + const second = host.request({ tool: 'handshake' }) + await settle() + + // Backpressure can hold a write callback past its own response. The channel + // is alive and was never stopped, so only the request id can tell that this + // report is stale — this is what pins the host-side guard on its own. + children[0].failQueuedWrite(0) + + expect(children[0].killed).toBe(false) + children[0].respond({ ok: true, capabilities: {} }) + await expect(second).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('shuts the helper down when idle and starts a new one on the next operation', async () => { + vi.useFakeTimers() + const { host, children } = createHost({ idleShutdownMs: 60_000 }) + + const first = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await first + await settle() + + expect(children[0].killed).toBe(false) + await vi.advanceTimersByTimeAsync(60_001) + expect(children[0].stdinEnded).toBe(true) + expect(children[0].killed).toBe(true) + + const next = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(2) + children[1].respond({ ok: true, capabilities: {} }) + await expect(next).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('disposes the helper and rejects the in-flight request', async () => { + const { host, children } = createHost() + const promise = host.request({ tool: 'click', app: 'Notepad' }) + await settle() + + host.dispose() + + expect(children[0].stdinEnded).toBe(true) + expect(children[0].killed).toBe(true) + await expect(promise).rejects.toThrow(/shut down/) + }) + + it('never respawns for a request queued behind dispose', async () => { + const { host, children } = createHost() + const first = host.request({ tool: 'handshake' }) + const queued = host.request({ tool: 'handshake' }) + await settle() + + host.dispose() + await expect(first).rejects.toBeInstanceOf(Error) + await expect(queued).rejects.toSatisfy(isRuntimeHostUnavailable) + await settle() + + expect(children).toHaveLength(1) + }) + + it('falls back to Bypass once when the execution policy blocks the start', async () => { + const { host, children, specs, warnings } = createHost() + + const promise = host.request({ tool: 'handshake' }) + await settle() + children[0].exit(1, POLICY_ERROR) + await settle() + + expect(children).toHaveLength(2) + expect(specs[1].args).toContain('Bypass') + children[1].respond({ ok: true, capabilities: {} }) + await expect(promise).resolves.toMatchObject({ ok: true }) + expect(warnings.some((line) => /trying Bypass/.test(line))).toBe(true) + + // A helper started under Bypass, so the diagnosis is proven and the fallback + // is remembered for the session rather than re-probed per call. + const next = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(2) + children[1].respond({ ok: true, capabilities: {} }) + await next + expect(warnings.some((line) => /returning to RemoteSigned/.test(line))).toBe(false) + host.dispose() + }) + + it('returns to RemoteSigned when Bypass does not start a helper either', async () => { + let clock = 1_000 + const { host, children, specs, warnings } = createHost({ cooldownMs: 60_000, now: () => clock }) + + // What AppLocker and WDAC constrained language mode look like: the same + // SecurityError category, but the block is at script load, so Bypass cannot + // lift it and the escalation was a misdiagnosis. + const promise = host.request({ tool: 'handshake' }) + await settle() + await failEveryStart(children, POLICY_ERROR) + await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable) + + expect(specs[1].args).toContain('Bypass') + expect(warnings.some((line) => /returning to RemoteSigned/.test(line))).toBe(true) + // The revert lands inside the outage, not just at its end: every attempt + // after the fallback is disproved is back on the preferred policy, so the + // misdiagnosis costs one Bypass command line rather than one per attempt. + expect(specs).toHaveLength(3) + expect(specs[2].args).not.toContain('Bypass') + + // Latching here would put the most heavily weighted MDE token on every + // later command line, on exactly the hardened host that is watching. + clock += 61_000 + const recovered = host.request({ tool: 'handshake' }) + await settle() + expect(specs.at(-1)?.args).not.toContain('Bypass') + children.at(-1)?.respond({ ok: true, capabilities: {} }) + await expect(recovered).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('reports itself unavailable when Bypass is also refused', async () => { + const { host, children } = createHost() + + const promise = host.request({ tool: 'handshake' }) + await settle() + await failEveryStart(children, POLICY_ERROR) + + await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable) + host.dispose() + }) + + it('reports itself unavailable when the helper cannot be spawned at all', async () => { + const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', { + powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe', + warn: () => {}, + spawn: () => { + throw new Error('spawn ENOENT') + } + }) + + await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable) + host.dispose() + }) + + it('retries a transient pre-answer death without the caller ever seeing it', async () => { + const { host, children } = createHost() + + const promise = host.request({ tool: 'handshake' }) + await settle() + children[0].exit(1, 'Add-Type : Cannot access the temporary directory') + await settle() + + expect(children).toHaveLength(2) + children[1].respond({ ok: true, capabilities: {} }) + + await expect(promise).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('gives up only after repeated start failures, then serves from the host again after the cooldown', async () => { + let clock = 1_000 + const { host, children, warnings } = createHost({ cooldownMs: 60_000, now: () => clock }) + + const failed = host.request({ tool: 'handshake' }) + await settle() + await failEveryStart(children, 'The term is not recognized') + await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable) + + const attempts = children.length + expect(attempts).toBe(3) + + // Inside the cooldown the host stays out of the way without respawning. + clock += 30_000 + await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable) + expect(children).toHaveLength(attempts) + + // Past it, the next operation re-probes rather than staying degraded forever. + clock += 31_000 + const recovered = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(attempts + 1) + children[attempts].respond({ ok: true, capabilities: {} }) + await expect(recovered).resolves.toMatchObject({ ok: true }) + + expect(warnings.at(-1)).toMatch(/recovered/) + host.dispose() + }) + + it('keeps the helper account of a reply it could not tag', async () => { + const { host, children } = createHost() + + const promise = host.request({ tool: 'handshake' }) + await settle() + const child = children[0] + // What an old runtime.ps1 sends when a request will not parse: a real error, + // with no id to route it by. The desync is honest, but replacing its message + // reports a broken stream and loses the only account of the cause. + child.respond({ ok: false, error: 'Invalid object passed in' }, child.pendingId() + 500) + + await expect(promise).rejects.toThrow( + /did not match the pending request: Invalid object passed in/ + ) + host.dispose() + }) + + it('does not charge a cooldown for requests the helper rejects as malformed', async () => { + const { host, children } = createHost({ cooldownMs: 60_000 }) + + // A tagged error is the helper working, not failing. Three of them used to + // arrive untagged, and three desync aborts is exactly the cooldown. + for (let round = 0; round < 3; round++) { + const promise = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: false, error: 'Invalid object passed in' }) + await expect(promise).resolves.toMatchObject({ ok: false }) + await settle() + } + + expect(children).toHaveLength(1) + const next = host.request({ tool: 'handshake' }) + await settle() + children[0].respond({ ok: true, capabilities: {} }) + await expect(next).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('fails a request that spends its whole timeout queued behind others', async () => { + vi.useFakeTimers() + const { host, children } = createHost({ requestTimeoutMs: 1_000 }) + + // Two ahead of it, because one puts the turn exactly on the deadline. + const first = host.request({ tool: 'get_app_state', app: 'Frozen' }) + const second = host.request({ tool: 'get_app_state', app: 'Frozen' }) + const queued = host.request({ tool: 'click', app: 'Notepad' }) + // Asserted before the clock moves: both reject while the test is still + // inside advanceTimersByTimeAsync. + const firstFailed = expect(first).rejects.toMatchObject({ code: 'action_timeout' }) + // Its own deadline, not the one it would inherit by reaching the head. + const queuedFailed = expect(queued).rejects.toMatchObject({ + code: 'action_timeout', + message: /waiting for earlier operations/ + }) + await settle() + expect(children[0].requests()).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(1_001) + await firstFailed + await queuedFailed + + // Drain past the abandoned request: it is never handed to a helper, because + // a click the caller has been told failed must not still land. + children[1].respond({ ok: true, state: {} }) + await expect(second).resolves.toMatchObject({ ok: true }) + await settle() + expect(children.flatMap((child) => child.requests())).not.toContainEqual( + expect.objectContaining({ tool: 'click' }) + ) + + // The request that gave up does not poison the queue behind it. + const next = host.request({ tool: 'handshake' }) + await settle() + children[1].respond({ ok: true, capabilities: {} }) + await expect(next).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + it('gives a queued request its full timeout once it reaches the helper', async () => { + vi.useFakeTimers() + const { host, children } = createHost({ requestTimeoutMs: 1_000 }) + + const head = host.request({ tool: 'handshake' }) + const queued = host.request({ tool: 'get_app_state', app: 'Slow' }) + await settle() + + await vi.advanceTimersByTimeAsync(900) + children[0].respond({ ok: true, capabilities: {} }) + await expect(head).resolves.toMatchObject({ ok: true }) + await settle() + + // Past the point the enqueue deadline would have fired: waiting its turn + // must not eat the budget the operation itself is entitled to. + await vi.advanceTimersByTimeAsync(900) + children[0].respond({ ok: true, state: {} }) + await expect(queued).resolves.toMatchObject({ ok: true }) + host.dispose() + }) + + // Both of these deliberately leave `now` unset: the bug was in the default the + // host picks, so a test that injects a clock cannot see it. + it('does not stretch the cooldown when the wall clock steps backwards', async () => { + const wallClock = vi.spyOn(Date, 'now').mockReturnValue(2_000_000_000_000) + const { host, children } = createHost({ cooldownMs: 60_000 }) + + const failed = host.request({ tool: 'handshake' }) + await settle() + await failEveryStart(children, 'The term is not recognized') + await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable) + + // An NTP correction, a VM snapshot restore, a user changing the clock. + wallClock.mockReturnValue(2_000_000_000_000 - 3_600_000) + + const refused = await host.request({ tool: 'handshake' }).then( + () => null, + (error: Error) => error + ) + expect(refused?.message).toMatch(/retrying the runtime host in/) + expect(remainingCooldownMs(refused)).toBeLessThanOrEqual(60_000) + host.dispose() + }) + + it('serves from the persistent helper again after a backwards clock step', async () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000_000_000_000) + const { host, children } = createHost({ cooldownMs: 25 }) + + const failed = host.request({ tool: 'handshake' }) + await settle() + await failEveryStart(children, 'The term is not recognized') + await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable) + const attempts = children.length + + vi.mocked(Date.now).mockReturnValue(2_000_000_000_000 - 3_600_000) + // Real elapsed time, because the clock under test is the real monotonic one. + await new Promise((resolve) => setTimeout(resolve, 60)) + + const recovered = host.request({ tool: 'handshake' }) + await settle() + expect(children).toHaveLength(attempts + 1) + children[attempts].respond({ ok: true, capabilities: {} }) + await expect(recovered).resolves.toMatchObject({ ok: true }) + host.dispose() + }) +}) diff --git a/src/main/computer/desktop-script-runtime-host.ts b/src/main/computer/desktop-script-runtime-host.ts new file mode 100644 index 00000000000..09aff5ec479 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.ts @@ -0,0 +1,384 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { reportComputerDiagnostic } from './computer-sidecar-diagnostics' +import { isReplayableTool } from './desktop-script-action' +import type { BridgeRequest, BridgeResponse } from './desktop-script-provider-types' +import { DesktopScriptRequestQueue } from './desktop-script-request-queue' +import { + startServeChannel, + type DesktopScriptServeChannel, + type RuntimeProcessSpawn +} from './desktop-script-serve-channel' +import { + MAX_START_ATTEMPTS, + RuntimeHostAvailability, + START_FAILURE_COOLDOWN_MS +} from './desktop-script-runtime-availability' +import { RuntimeClientError } from './runtime-client-error' +import { + isExecutionPolicyBlocked, + windowsPowerShellRuntimeArgs +} from './windows-powershell-execution-policy' + +const REQUEST_TIMEOUT_MS = 30_000 +const IDLE_SHUTDOWN_MS = 120_000 + +/** Code the client keys on to serve this one operation from the one-shot bridge. */ +export const RUNTIME_HOST_UNAVAILABLE = 'runtime_host_unavailable' + +export type DesktopScriptRuntimeHostOptions = { + spawn?: RuntimeProcessSpawn + powerShellPath?: () => string + requestTimeoutMs?: number + idleShutdownMs?: number + cooldownMs?: number + now?: () => number + warn?: (message: string) => void +} + +type PendingRequest = { + id: number + resolve: (response: BridgeResponse) => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +export function isRuntimeHostUnavailable(error: unknown): boolean { + return error instanceof RuntimeClientError && error.code === RUNTIME_HOST_UNAVAILABLE +} + +/** + * One long-lived `runtime.ps1 -Serve` process serving every computer-use + * operation over NDJSON on stdin/stdout. + * + * Why persistent: the one-shot bridge started a powershell.exe per click, and + * each one re-emitted the script's inline `Add-Type` P/Invoke assembly, which + * Defender for Endpoint reports as suspicious MSIL emission alongside the + * screen capture. Compiling once per session collapses a burst of short-lived + * PIDs into a single process. + * + * Requests are strictly serialized, and each carries an id the helper echoes. + * Serialization alone would leave a single stray line answering every later + * request with the previous response — silently acting on stale element + * indexes, with no error raised — so the id is checked and a mismatch is fatal + * to the child rather than merely logged. + */ +export class DesktopScriptRuntimeHost { + private channel: DesktopScriptServeChannel | null = null + private pending: PendingRequest | null = null + private idleTimer: NodeJS.Timeout | null = null + private childReady = false + private childAnswered = false + /** + * Set once any helper has announced itself, which proves the script on disk + * speaks the ready protocol. Until then a mutating request is not replayed + * even on a clean start failure, because ORCA_COMPUTER_DESKTOP_SCRIPT_PROVIDER_PATH + * can point at an older runtime.ps1 that simply never announces. + */ + private readyProtocolConfirmed = false + private disposed = false + private nextRequestId = 1 + private readonly availability: RuntimeHostAvailability + private readonly queue: DesktopScriptRequestQueue + private readonly requestTimeoutMs: number + private readonly idleShutdownMs: number + + constructor( + private readonly scriptPath: string, + private readonly options: DesktopScriptRuntimeHostOptions = {} + ) { + this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS + this.idleShutdownMs = options.idleShutdownMs ?? IDLE_SHUTDOWN_MS + this.queue = new DesktopScriptRequestQueue(this.requestTimeoutMs, () => this.armIdleTimer()) + this.availability = new RuntimeHostAvailability( + options.cooldownMs ?? START_FAILURE_COOLDOWN_MS, + (message) => (options.warn ?? reportComputerDiagnostic)(message), + options.now + ) + } + + request(request: BridgeRequest): Promise { + return this.queue.enqueue(() => this.send(request)) + } + + /** Permanently stop this host. Callers build a new one for a new session. */ + dispose(): void { + this.disposed = true + this.clearIdleTimer() + this.availability.clearCooldown() + this.stopChannel() + this.rejectPending( + new RuntimeClientError('accessibility_error', 'desktop provider runtime host was shut down') + ) + } + + private async send(request: BridgeRequest): Promise { + this.clearIdleTimer() + // Why checked here and not only on entry: requests queue, and dispose can + // land while one waits its turn. Without this a teardown respawns a helper. + if (this.disposed) { + throw this.unavailableError('runtime host was disposed') + } + const cooldown = this.availability.remainingCooldown() + if (cooldown > 0) { + throw this.unavailableError(`retrying the runtime host in ${cooldown}ms`) + } + let lastError: unknown + for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt++) { + try { + const response = await this.sendOnce(request) + this.availability.recordSuccess() + return response + } catch (error) { + lastError = error + if (this.availability.policyRetryPending) { + this.availability.escalateExecutionPolicy() + continue + } + // Only this error proves no helper started, which is what disproves the + // escalation; a helper that started and then died proves the opposite. + if (isRuntimeHostUnavailable(error)) { + this.availability.abandonUnprovenFallback() + } + // A helper that answered and then died is a crash, not a bad start: the + // caller sees it and the next operation gets a fresh process — unless it + // keeps happening, which is thrash the one-shot bridge should absorb. + if (!isRuntimeHostUnavailable(error) || !this.mayReplay(request)) { + if (this.availability.exhausted) { + this.availability.enterCooldown() + } + throw error + } + this.availability.warn( + `runtime host failed to start (attempt ${attempt}/${MAX_START_ATTEMPTS}): ${errorText(error)}` + ) + } + } + this.availability.enterCooldown() + throw lastError + } + + private sendOnce(request: BridgeRequest): Promise { + let channel: DesktopScriptServeChannel + try { + channel = this.ensureChannel() + } catch (error) { + this.availability.recordFailure() + return Promise.reject(this.unavailableError(errorText(error))) + } + const id = this.nextRequestId++ + return new Promise((resolve, reject) => { + // Why kill rather than wait: a hung UI Automation call cannot be + // cancelled, so the process itself is the only thing left to reclaim. + const timer = setTimeout(() => { + this.abortChannel( + new RuntimeClientError( + 'action_timeout', + `desktop provider timed out after ${this.requestTimeoutMs}ms` + ) + ) + }, this.requestTimeoutMs) + timer.unref?.() + this.pending = { id, resolve, reject, timer } + channel.write(`${JSON.stringify({ ...request, requestId: id })}\n`, (error) => { + // Bind the report to what it was written for: a late callback must not + // charge a second failure for this operation, nor stop a replacement + // helper and reject a later request with this one's error. Deliberately + // redundant with the channel's own closed guard — keep both. This one + // also covers a live channel whose request has already been answered, + // which the channel cannot see; that case is what pins it. + // + // Redundant does not mean untested: removing either guard alone fails a + // test, so neither can be deleted as "the one the other covers". + if (this.channel !== channel || this.pending?.id !== id) { + return + } + this.abortChannel(new RuntimeClientError('accessibility_error', error.message)) + }) + }) + } + + private ensureChannel(): DesktopScriptServeChannel { + if (this.channel) { + return this.channel + } + this.childReady = false + this.childAnswered = false + const channel: DesktopScriptServeChannel = startServeChannel( + { + program: (this.options.powerShellPath ?? windowsPowerShellPath)(), + args: windowsPowerShellRuntimeArgs(this.scriptPath, this.availability.executionPolicy, [ + '-Serve' + ]), + env: process.env + }, + this.options.spawn ?? spawnProcess, + { + onLine: (line) => this.deliver(line), + // A replaced channel can still report; that must not fail the live one. + onGone: (detail) => { + if (this.channel === channel) { + this.handleGone(detail) + } + }, + onOverflow: () => + this.abortChannel( + new RuntimeClientError( + 'accessibility_error', + 'desktop provider response exceeded the runtime host buffer' + ) + ) + } + ) + this.channel = channel + return channel + } + + /** + * Whether the helper that just died can be proved not to have run the request. + * + * Why proof and not inference: "no reply came back" is not "nothing happened". + * runtime.ps1 synthesizes the input and only then builds the snapshot, which + * allocates a full-window bitmap and walks the UIA tree — a native fault there + * is uncatchable and would leave a click already delivered. Retrying on that + * inference turns one requested click into four. + */ + private mayReplay(request: BridgeRequest): boolean { + if (this.childReady || this.childAnswered) { + return false + } + return this.readyProtocolConfirmed || isReplayableTool(request.tool) + } + + private deliver(line: string): void { + let parsed: Record + try { + parsed = JSON.parse(line) as Record + } catch { + // Not a response at all — a PowerShell banner, a stray write. Dropping it + // is safe now that the id below is what decides which request is answered, + // and it keeps a chatty console from making the helper unusable. + return + } + // The readiness announcement carries no request id and answers nothing. + if (parsed.ready === true && parsed.requestId === undefined) { + this.childReady = true + this.readyProtocolConfirmed = true + this.availability.confirmExecutionPolicy() + return + } + const pending = this.pending + if (!pending || parsed.requestId !== pending.id) { + // One unmatched reply would otherwise shift every later response by one. + // Carry the helper's own message when it sent one: a line it could not tag + // with an id is usually the only account of what went wrong, and reporting + // a bare desync in its place loses the cause for good. + const reported = typeof parsed.error === 'string' ? `: ${parsed.error}` : '' + this.abortChannel( + new RuntimeClientError( + 'accessibility_error', + `desktop provider response did not match the pending request${reported}` + ) + ) + return + } + // Only a reply this host can prove is its own counts as the helper working. + this.childAnswered = true + this.pending = null + clearTimeout(pending.timer) + const { requestId: _echoed, ...response } = parsed + pending.resolve(response as BridgeResponse) + } + + private handleGone(detail: string): void { + const started = this.childReady || this.childAnswered + this.channel = null + this.availability.recordFailure() + if (!started && this.availability.atPreferredPolicy && isExecutionPolicyBlocked(detail)) { + this.availability.requestPolicyRetry() + // Unavailable rather than a generic error, because this can now be the + // final attempt: reverting an unproven escalation puts the host back on + // the preferred policy, so a later attempt can land here again. Only this + // code routes the operation to the one-shot bridge, which carries its own + // policy fallback; anything else fails the operation outright. + this.rejectPending(this.unavailableError(detail)) + return + } + if (!started) { + this.rejectPending(this.unavailableError(detail)) + return + } + this.rejectPending( + new RuntimeClientError( + 'accessibility_error', + `desktop provider runtime host exited: ${detail}` + ) + ) + } + + /** + * Stop a helper this host has judged unusable — a timeout, a desynchronised + * reply, an oversized line. + * + * Why it counts as a failure: stopping the channel suppresses the exit + * handler, so without this these paths bypassed the accounting entirely and a + * helper that failed this way on every operation was respawned once per + * operation forever — the burst this host exists to remove, restored through + * its own recovery path. + */ + private abortChannel(error: Error): void { + this.stopChannel() + this.availability.recordFailure() + this.availability.warn(`runtime host helper stopped: ${error.message}`) + this.rejectPending(error) + } + + private stopChannel(): void { + const channel = this.channel + this.channel = null + channel?.stop() + } + + private takePending(): PendingRequest | null { + const pending = this.pending + this.pending = null + if (pending) { + clearTimeout(pending.timer) + } + return pending + } + + private rejectPending(error: Error): void { + this.takePending()?.reject(error) + } + + private armIdleTimer(): void { + this.clearIdleTimer() + if (!this.channel) { + return + } + this.idleTimer = setTimeout(() => { + this.idleTimer = null + this.stopChannel() + }, this.idleShutdownMs) + this.idleTimer.unref?.() + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer) + this.idleTimer = null + } + } + + private unavailableError(message: string): RuntimeClientError { + return new RuntimeClientError( + RUNTIME_HOST_UNAVAILABLE, + `desktop provider runtime host could not start: ${message}` + ) + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/computer/desktop-script-runtime-host.win32.test.ts b/src/main/computer/desktop-script-runtime-host.win32.test.ts new file mode 100644 index 00000000000..76927a7dd65 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.win32.test.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { DesktopScriptRuntimeHost } from './desktop-script-runtime-host' +import { startServeChannel } from './desktop-script-serve-channel' +import { + PREFERRED_WINDOWS_EXECUTION_POLICY, + windowsPowerShellRuntimeArgs +} from './windows-powershell-execution-policy' + +/** + * The other half of the serve-mode proof: the unit test drives a fake child, + * this one drives the real `runtime.ps1 -Serve` on a real Windows box. + * + * Both are needed. The framing that matters — one NDJSON line per response, + * megabyte-scale screenshot payloads, a console writer that actually flushes — + * only exists in PowerShell, and a fake child cannot disprove any of it. + * + * Runs only on win32; skipped elsewhere. + */ +const describeOnWindows = process.platform === 'win32' ? describe : describe.skip + +const SCRIPT_PATH = resolve(__dirname, '../../../native/computer-use-windows/runtime.ps1') + +describeOnWindows('runtime.ps1 serve mode', () => { + let host: DesktopScriptRuntimeHost | null = null + let spawns = 0 + + function startHost(): DesktopScriptRuntimeHost { + spawns = 0 + host = new DesktopScriptRuntimeHost(SCRIPT_PATH, { + warn: () => {}, + spawn: (spec) => { + spawns++ + return spawnProcess(spec) + } + }) + return host + } + + afterEach(() => { + host?.dispose() + host = null + }) + + it('answers repeated operations from a single PowerShell process', async () => { + const runtime = startHost() + + await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ + ok: true, + capabilities: { protocolVersion: 1, provider: 'orca-computer-use-windows' } + }) + + const apps = await runtime.request({ tool: 'list_apps' }) + expect(apps.ok).toBe(true) + expect(Array.isArray(apps.apps)).toBe(true) + + await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ ok: true }) + + expect(spawns).toBe(1) + }) + + it('returns a structured error for a bad request without killing the helper', async () => { + const runtime = startHost() + + await expect(runtime.request({ tool: 'not_a_tool' })).resolves.toMatchObject({ ok: false }) + await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ ok: true }) + expect(spawns).toBe(1) + }) + + /** + * The host can only write well-formed JSON, so the parse-failure branch of the + * serve loop is unreachable through it. Driving the channel directly is the + * only way to prove what the real PowerShell answers. + */ + it('echoes the id it can recover when a request will not parse', async () => { + const answer = await answerRawLine('{"tool":"handshake","requestId":7') + + // Tagged, so the host resolves the waiting request with a failed operation + // instead of reading an untagged line as a desynchronised stream. + expect(answer).toMatchObject({ ok: false, requestId: 7 }) + expect(String(answer.error)).not.toBe('') + }) + + it('reports an error for a line with no recoverable id', async () => { + const answer = await answerRawLine('{"tool":"handshake"') + + expect(answer).toMatchObject({ ok: false }) + expect(answer.requestId).toBeUndefined() + expect(String(answer.error)).not.toBe('') + }) +}) + +/** One raw line into a real `runtime.ps1 -Serve`, and the line it writes back. */ +function answerRawLine(raw: string): Promise> { + return new Promise((settle, fail) => { + const channel = startServeChannel( + { + program: windowsPowerShellPath(), + args: windowsPowerShellRuntimeArgs(SCRIPT_PATH, PREFERRED_WINDOWS_EXECUTION_POLICY, [ + '-Serve' + ]), + env: process.env + }, + spawnProcess, + { + onLine: (line) => { + let parsed: Record + try { + parsed = JSON.parse(line) as Record + } catch { + return + } + if (parsed.ready === true) { + channel.write(`${raw}\n`, fail) + return + } + channel.stop() + settle(parsed) + }, + onGone: (detail) => fail(new Error(`helper exited before answering: ${detail}`)), + onOverflow: () => { + channel.stop() + fail(new Error('helper overflowed the response buffer')) + } + } + ) + }) +} diff --git a/src/main/computer/desktop-script-serve-channel.test.ts b/src/main/computer/desktop-script-serve-channel.test.ts new file mode 100644 index 00000000000..80a5dd491d3 --- /dev/null +++ b/src/main/computer/desktop-script-serve-channel.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { DesktopScriptServeChannel, type RuntimeChildProcess } from './desktop-script-serve-channel' + +class FakeChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + readonly writes: string[] = [] + killed = false + private readonly pendingWrites: ((error?: Error | null) => void)[] = [] + + readonly stdin = { + write: (chunk: string, callback?: (error?: Error | null) => void): boolean => { + this.writes.push(chunk) + if (callback) { + this.pendingWrites.push(callback) + } + return true + }, + end: (): void => {}, + on: (): void => {} + } + + kill(): boolean { + this.killed = true + return true + } + + /** What a destroyed stdin does to writes still queued at teardown. */ + failQueuedWrites(): void { + for (const callback of this.pendingWrites.splice(0)) { + callback(new Error('ERR_STREAM_DESTROYED')) + } + } +} + +function createChannel() { + const child = new FakeChild() + const handlers = { onLine: vi.fn(), onGone: vi.fn(), onOverflow: vi.fn() } + const channel = new DesktopScriptServeChannel(child as unknown as RuntimeChildProcess, handlers) + return { channel, child, handlers } +} + +describe('DesktopScriptServeChannel', () => { + it('splits responses into lines and tolerates a trailing carriage return', () => { + const { child, handlers } = createChannel() + + child.stdout.emit('data', Buffer.from('{"a":1}\r\n{"b":2}\n', 'utf8')) + + expect(handlers.onLine.mock.calls.map(([line]) => line)).toEqual(['{"a":1}', '{"b":2}']) + }) + + it('reports the exit reason with the stderr tail', () => { + const { child, handlers } = createChannel() + + child.stderr.emit('data', Buffer.from('it broke', 'utf8')) + child.emit('close', 1, null) + + expect(handlers.onGone).toHaveBeenCalledWith('code 1: it broke') + }) + + describe('once stopped', () => { + /** + * The channel's half of the stale-callback guard, pinned here rather than + * through the host: the host refuses a stale report too, so a host-level + * test passes with either guard alone and neither ends up covered. + */ + it('accepts no further writes', () => { + const { channel, child } = createChannel() + + channel.stop() + channel.write('{"tool":"click"}\n', vi.fn()) + + expect(child.writes).toEqual([]) + }) + + it('reports no error from a write that was already queued', () => { + const { channel, child } = createChannel() + const onError = vi.fn() + + channel.write('{"tool":"click"}\n', onError) + channel.stop() + child.failQueuedWrites() + + expect(onError).not.toHaveBeenCalled() + }) + + it('reports neither lines nor the exit it was asked to cause', () => { + const { channel, child, handlers } = createChannel() + + channel.stop() + child.stdout.emit('data', Buffer.from('{"a":1}\n', 'utf8')) + child.emit('close', 0, null) + + expect(handlers.onLine).not.toHaveBeenCalled() + expect(handlers.onGone).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/computer/desktop-script-serve-channel.ts b/src/main/computer/desktop-script-serve-channel.ts new file mode 100644 index 00000000000..afabb47962a --- /dev/null +++ b/src/main/computer/desktop-script-serve-channel.ts @@ -0,0 +1,145 @@ +import { StringDecoder } from 'node:string_decoder' +import type { ProcessSpec } from '../../shared/child-process/process-spec' +import type { spawnProcess } from '../../shared/child-process/run-process' + +/** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */ +export type RuntimeChildProcess = ReturnType + +export type RuntimeProcessSpawn = (spec: ProcessSpec) => RuntimeChildProcess + +/** UTF-16 units, not bytes — this bounds the buffer, it is not a payload contract. */ +const MAX_RESPONSE_CHARS = 20 * 1024 * 1024 +const MAX_STDERR_CHARS = 4096 + +export type ServeChannelHandlers = { + /** One complete line from the helper, without its terminator. */ + onLine: (line: string) => void + /** The helper is gone; detail carries the exit reason and its stderr tail. */ + onGone: (detail: string) => void + /** The helper produced more than one buffer's worth without a line break. */ + onOverflow: () => void +} + +/** + * One `runtime.ps1 -Serve` child, framed as NDJSON lines. + * + * Split from the host so the host reads as what it is — a queue, a retry policy + * and a correlation check — rather than that plus stream plumbing. Responses + * carry base64 screenshots and routinely exceed a megabyte, so lines are + * reassembled across chunks with a decoder that survives a code point split + * across a chunk boundary. + */ +export class DesktopScriptServeChannel { + private readonly decoder = new StringDecoder('utf8') + private buffer = '' + private stderrTail = '' + private detach: (() => void) | null = null + private closed = false + + constructor( + private readonly child: RuntimeChildProcess, + private readonly handlers: ServeChannelHandlers + ) { + const onStdout = (chunk: Buffer | string): void => this.readStdout(chunk) + const onStderr = (chunk: Buffer | string): void => { + this.stderrTail = `${this.stderrTail}${chunk.toString()}`.slice(-MAX_STDERR_CHARS) + } + // Why close and not exit: the caller classifies the failure from stderr, and + // only close guarantees the stdio streams were drained first. + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => + this.reportGone(signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`) + const onError = (error: Error): void => this.reportGone(error.message) + child.stdout.on('data', onStdout) + child.stderr.on('data', onStderr) + child.once('close', onClose) + child.once('error', onError) + // An unhandled stream error is an uncaught exception in the main process. + child.stdin.on('error', () => {}) + this.detach = (): void => { + child.stdout.off('data', onStdout) + child.stderr.off('data', onStderr) + child.off('close', onClose) + child.off('error', onError) + child.on('error', () => {}) + } + } + + write(payload: string, onError: (error: Error) => void): void { + if (this.closed) { + return + } + this.child.stdin.write(payload, (error) => { + // A destroyed stdin calls back after stop(); reporting then charges the + // caller a second failure for one operation. Deliberately redundant with + // the host's own staleness check — keep both, and note that each is + // pinned separately, this one by the "once stopped" tests here. + if (error && !this.closed) { + onError(error) + } + }) + } + + /** Stop the helper and go silent; handlers are not called afterwards. */ + stop(): void { + if (this.closed) { + return + } + this.closed = true + this.detach?.() + this.detach = null + this.buffer = '' + // Closing stdin ends the serve loop; the kill covers a wedged helper. + try { + this.child.stdin.end() + } catch { + /* already closed */ + } + this.child.kill() + } + + private reportGone(detail: string): void { + if (this.closed) { + return + } + const text = [detail, this.stderrTail.trim()].filter(Boolean).join(': ') + this.closed = true + this.detach?.() + this.detach = null + this.handlers.onGone(text) + } + + private readStdout(chunk: Buffer | string): void { + if (this.closed) { + return + } + this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk) + if (this.buffer.length > MAX_RESPONSE_CHARS) { + this.buffer = '' + this.handlers.onOverflow() + return + } + for (let newline = this.buffer.indexOf('\n'); newline >= 0;) { + // Slice a trailing CR off by index; trimming copies the whole payload. + const end = newline > 0 && this.buffer.charCodeAt(newline - 1) === 13 ? newline - 1 : newline + const line = this.buffer.slice(0, end) + this.buffer = this.buffer.slice(newline + 1) + if (line.length > 0) { + this.handlers.onLine(line) + // A handler may have stopped this channel; stop reading its backlog. + if (this.closed) { + this.buffer = '' + return + } + } + newline = this.buffer.indexOf('\n') + } + } +} + +export function startServeChannel( + spec: ProcessSpec, + spawn: RuntimeProcessSpawn, + handlers: ServeChannelHandlers +): DesktopScriptServeChannel { + return new DesktopScriptServeChannel(spawn(spec), handlers) +} diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index 489c463dd93..23af1aa603b 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -9,6 +9,7 @@ import type { ComputerSnapshotResult } from '../../shared/runtime-types' import { normalizeComputerActionResult } from './computer-action-verification-normalization' +import { isComputerSidecarDiagnostic, logComputerDiagnostic } from './computer-sidecar-diagnostics' import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation' import { RuntimeClientError } from './runtime-client-error' @@ -245,6 +246,11 @@ class ComputerSidecarProcess { } private handleMessage(message: unknown): void { + // The sidecar's stdio is piped and unread, so its warnings arrive here. + if (isComputerSidecarDiagnostic(message)) { + logComputerDiagnostic(message.message) + return + } if (!isSidecarResponse(message)) { return } diff --git a/src/main/computer/sidecar-entry.ts b/src/main/computer/sidecar-entry.ts index 8489f71e7f3..961d2261ede 100644 --- a/src/main/computer/sidecar-entry.ts +++ b/src/main/computer/sidecar-entry.ts @@ -8,6 +8,11 @@ type SidecarRequest = { params?: Record } +// Why disconnect carries the weight on Windows: the parent stops the sidecar +// with kill('SIGTERM'), which is TerminateProcess there, so the SIGTERM handler +// below never runs and teardown rides on the IPC channel closing instead. A +// helper wedged inside a UI Automation call can still outlive that and deliver +// input after teardown; only a real signal would preempt it. process.once('disconnect', shutdownProviders) process.once('SIGTERM', () => { shutdownProviders() diff --git a/src/main/computer/windows-powershell-execution-policy.test.ts b/src/main/computer/windows-powershell-execution-policy.test.ts new file mode 100644 index 00000000000..033eec58fb5 --- /dev/null +++ b/src/main/computer/windows-powershell-execution-policy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + FALLBACK_WINDOWS_EXECUTION_POLICY, + PREFERRED_WINDOWS_EXECUTION_POLICY, + isExecutionPolicyBlocked, + windowsPowerShellRuntimeArgs +} from './windows-powershell-execution-policy' + +/** + * Captured from powershell.exe on Windows, verbatim including the hard wrapping. + * + * The discriminator has to be pinned in both directions: a policy block must + * escalate once, and a plain access denial must not, because escalation is + * sticky for the session and lands on `-ExecutionPolicy Bypass`. + */ +const POLICY_BLOCKED_RESTRICTED = [ + 'File C:\\Temp\\runtime.ps1 cannot be loaded because running scripts is disabled on this system. For more ', + 'information, see about_Execution_Policies at https:/go.microsoft.com/fwlink/?LinkID=135170.', + ' + CategoryInfo : SecurityError: (:) [], ParentContainsErrorRecordException', + ' + FullyQualifiedErrorId : UnauthorizedAccess' +].join('\r\n') + +const POLICY_BLOCKED_REMOTE_SIGNED = [ + 'File C:\\Temp\\runtime.ps1 cannot be loaded. The file ', + 'C:\\Temp\\runtime.ps1 is not digitally signed. You cannot run this script on the current system. For more ', + 'information about running scripts and setting execution policy, see about_Execution_Policies at https:/go.microsoft.com/fwlink/?LinkID=135170.', + ' + CategoryInfo : SecurityError: (:) [], ParentContainsErrorRecordException', + ' + FullyQualifiedErrorId : UnauthorizedAccess' +].join('\r\n') + +/** No execution policy involved: .NET refusing a file the process may not read. */ +const GENUINE_ACCESS_DENIED = [ + 'Exception calling "ReadAllText" with "1" argument(s): "Access to the path \'C:\\Windows\\System32\\config\\SAM\' is denied."', + 'At C:\\Temp\\runtime.ps1:1 char:1', + '+ [System.IO.File]::ReadAllText("C:\\Windows\\System32\\config\\SAM")', + '+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + ' + CategoryInfo : NotSpecified: (:) [], MethodInvocationException', + ' + FullyQualifiedErrorId : UnauthorizedAccessException' +].join('\r\n') + +describe('isExecutionPolicyBlocked', () => { + it('recognises a policy block under either policy', () => { + expect(isExecutionPolicyBlocked(POLICY_BLOCKED_RESTRICTED)).toBe(true) + expect(isExecutionPolicyBlocked(POLICY_BLOCKED_REMOTE_SIGNED)).toBe(true) + }) + + it('does not read a plain access denial as a policy block', () => { + // UnauthorizedAccessException merely starts with the policy error id. Without + // the word boundary this matched, and one locked file downgraded the whole + // session to Bypass with no path back. + expect(isExecutionPolicyBlocked(GENUINE_ACCESS_DENIED)).toBe(false) + }) + + it('keeps recognising a block when the record labels are localized', () => { + // The labels are translated on a non-English host; the ids and the help + // topic are not, so the match must not depend on the labels. + const localized = POLICY_BLOCKED_RESTRICTED.replace('CategoryInfo', 'Categoria') + .replace('FullyQualifiedErrorId', 'IdErroreCompleto') + .replace( + 'cannot be loaded because running scripts is disabled on this system', + 'non puo essere caricato' + ) + expect(isExecutionPolicyBlocked(localized)).toBe(true) + }) + + it('ignores the failures the helper reports every day', () => { + expect(isExecutionPolicyBlocked('code 1: The term is not recognized')).toBe(false) + expect(isExecutionPolicyBlocked('Add-Type : Cannot access the temporary directory')).toBe(false) + expect(isExecutionPolicyBlocked('')).toBe(false) + }) +}) + +describe('windowsPowerShellRuntimeArgs', () => { + it('never emits Bypass unless the caller escalated to it', () => { + const preferred = windowsPowerShellRuntimeArgs( + 'C:\\orca\\runtime.ps1', + PREFERRED_WINDOWS_EXECUTION_POLICY, + ['-Serve'] + ) + expect(preferred).not.toContain(FALLBACK_WINDOWS_EXECUTION_POLICY) + expect(preferred).toEqual([ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'RemoteSigned', + '-File', + 'C:\\orca\\runtime.ps1', + '-Serve' + ]) + }) +}) diff --git a/src/main/computer/windows-powershell-execution-policy.ts b/src/main/computer/windows-powershell-execution-policy.ts new file mode 100644 index 00000000000..204f5b02b06 --- /dev/null +++ b/src/main/computer/windows-powershell-execution-policy.ts @@ -0,0 +1,59 @@ +/** + * Execution-policy handling for the Windows computer-use runtime script. + * + * Why not `Bypass` outright: it is the highest-weighted token on a + * powershell.exe command line for Defender for Endpoint, and the shipped + * runtime.ps1 does not need it — NSIS extraction writes no Zone.Identifier, so + * an unsigned local script runs under `RemoteSigned`. `Restricted` is still the + * Windows client default though, so a policy-blocked start must fall back once + * rather than leaving computer use broken. + */ +export type WindowsExecutionPolicy = 'RemoteSigned' | 'Bypass' + +export const PREFERRED_WINDOWS_EXECUTION_POLICY: WindowsExecutionPolicy = 'RemoteSigned' +export const FALLBACK_WINDOWS_EXECUTION_POLICY: WindowsExecutionPolicy = 'Bypass' + +/** + * Matches the SecurityError PowerShell emits for `-File` under a blocking policy. + * + * Every alternative is a PowerShell or .NET identifier, never prose. The prose + * differs by policy ("running scripts is disabled" under Restricted, "is not + * digitally signed" under RemoteSigned), is localized, and PowerShell hard-wraps + * it mid-sentence at the console width, so it can anchor nothing. + * + * The `\b` after UnauthorizedAccess is the whole discriminator and must not be + * dropped. `UnauthorizedAccess` is the FullyQualifiedErrorId of a policy block, + * but it is also a strict prefix of `UnauthorizedAccessException`, which .NET + * raises for an ordinary locked or ACL-denied file: an AV scan holding + * runtime.ps1, a locked CSC temp directory, a roaming-profile hiccup. Matching + * that escalates to `Bypass` for the rest of the session — the exact command + * line token this stack exists to stop emitting — and on the one-shot path + * replays an operation that already ran. + * + * Anchoring on the `FullyQualifiedErrorId:`/`CategoryInfo:` labels would be more + * precise still, but the labels are localized where these values are not, so a + * non-English host would stop recognising a real block and lose the fallback. + */ +const EXECUTION_POLICY_BLOCKED = /\bUnauthorizedAccess\b|\bSecurityError\b|about_Execution_Policies/ + +export function isExecutionPolicyBlocked(text: string): boolean { + return EXECUTION_POLICY_BLOCKED.test(text) +} + +export function windowsPowerShellRuntimeArgs( + scriptPath: string, + policy: WindowsExecutionPolicy, + scriptArgs: readonly string[] = [] +): string[] { + return [ + // -NoLogo: a banner on stdout would be read as a malformed response line. + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + policy, + '-File', + scriptPath, + ...scriptArgs + ] +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 0d2323fd449..e899a67ed43 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -5,12 +5,13 @@ import { mkdtempSync, readFileSync, readdirSync, + renameSync, rmSync, utimesSync, writeFileSync } from 'node:fs' import os from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' @@ -141,12 +142,11 @@ describe('buildDaemonHostManifest', () => { entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' }) const byDest = new Map(ops.map((op) => [op.destRel, op])) - // The host exe is renamed to a distinct image name (NOT the source basename) - // so the NSIS updater's name-based `taskkill /IM Orca.exe` can't kill it. - expect(byDest.get('orca-terminal-daemon.exe')?.kind).toBe('file') - expect(byDest.has('Orca.exe')).toBe(false) + // The host exe keeps the source basename: a verbatim, signature-preserving copy with no + // image-name mismatch. What escapes the updater's sweep is the path, not the name. + expect(byDest.get('Orca.exe')?.kind).toBe('file') const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe') - expect(exeOp?.destRel).not.toBe('Orca.exe') + expect(exeOp?.destRel).toBe('Orca.exe') // V8/ICU data blobs are read by the Electron bootstrap and kept. expect(byDest.has('icudtl.dat')).toBe(true) // GPU/graphics DLLs are never loaded by the windowless host, so not copied. @@ -170,7 +170,7 @@ describe('materializeRelocatedDaemonHost', () => { const result = materializeRelocatedDaemonHost() expect(result).not.toBeNull() const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') - expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(result?.execPath).toBe(join(dest, 'Orca.exe')) expect(result?.entryPath).toBe( join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') ) @@ -203,6 +203,28 @@ describe('materializeRelocatedDaemonHost', () => { expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js') }) + it('copies the exe verbatim: same file name and same bytes as the install-dir exe', () => { + const result = materializeRelocatedDaemonHost() + const sourceExe = join(installDir, 'Orca.exe') + // Byte-for-byte under the same name is what preserves the Authenticode signature and leaves + // no renamed-image signal for endpoint detection to read as masquerading. + expect(basename(result!.execPath)).toBe(basename(sourceExe)) + expect(readFileSync(result!.execPath)).toEqual(readFileSync(sourceExe)) + }) + + it('tracks a differently-named app exe rather than pinning an image name of its own', () => { + // A dev-channel or rebranded build ships a different executableName; the host copy must follow + // it, which is what keeps the copy verbatim instead of reintroducing a name mismatch. + renameSync(join(installDir, 'Orca.exe'), join(installDir, 'Orca Nightly.exe')) + setProcessProp('execPath', join(installDir, 'Orca Nightly.exe')) + const result = materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + expect(result?.execPath).toBe(join(dest, 'Orca Nightly.exe')) + expect(existsSync(join(dest, 'orca-terminal-daemon.exe'))).toBe(false) + // Re-resolution must agree with materialization or the fork would target a missing exe. + expect(getRelocatedDaemonHost()?.execPath).toBe(join(dest, 'Orca Nightly.exe')) + }) + it('is idempotent: a valid marker short-circuits without recopying', () => { materializeRelocatedDaemonHost() const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') @@ -210,7 +232,7 @@ describe('materializeRelocatedDaemonHost', () => { const sentinel = join(dest, 'sentinel.txt') writeFileSync(sentinel, 'keep') const result = materializeRelocatedDaemonHost() - expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(result?.execPath).toBe(join(dest, 'Orca.exe')) expect(existsSync(sentinel)).toBe(true) }) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 6d94bea06e4..13aab9fd8f9 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -22,6 +22,10 @@ import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-pr * imaged under it, which would otherwise kill the daemon and its live terminals. The relocated exe is a * run-as-node Orca.exe copy (not node.exe) so there's no console flash and asar still resolves. Fail-open: * any failure returns null and the caller forks the install-dir host (pre-relocation behavior). + * + * What escapes the updater is the PATH, not the file name: electron-builder's kill sweep selects + * processes whose image path sits under $INSTDIR. See docs/reference/windows-daemon-host-relocation.md + * for the survival contract and why the exe is copied verbatim rather than renamed. */ export type RelocatedDaemonHost = { @@ -37,8 +41,14 @@ const MARKER_NAME = '.materialized.json' // LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' -// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it. -const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe' +/** + * The host exe keeps the app exe's own file name, so the relocated image is a byte-for-byte, + * name-included copy of a signed binary — nothing for EDR to read as a renamed image (MITRE T1036). + * Survival comes from the path (see the module header). The one name-sensitive updater path is the + * no-PowerShell `taskkill /IM` fallback, where the daemon is killed and terminals cold-restore — + * the documented pre-relocation outcome, not a failure. + */ +const daemonHostExeName = (execPath: string): string => winPath.basename(execPath) // V8 snapshots + ICU data the Electron bootstrap reads even under ELECTRON_RUN_AS_NODE; siblings of Orca.exe. const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] @@ -146,8 +156,8 @@ export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources const ops: CopyOp[] = [] - // Host exe (renamed) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved). - ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' }) + // Host exe (verbatim name) + V8/ICU blobs at dest root. Top-level DLLs omitted: GPU/media libs a windowless run-as-node host never loads (~48MB saved). + ops.push({ sourcePath: execPath, destRel: daemonHostExeName(execPath), kind: 'file' }) for (const name of RUNTIME_DATA_FILES) { ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) } @@ -245,7 +255,7 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!marker || marker.version !== version) { return null } - const execPath = join(dest, DAEMON_HOST_EXE_NAME) + const execPath = join(dest, daemonHostExeName(sources.execPath)) const entryPath = destPath(dest, marker.entryRelPath) if (!existsSync(execPath) || !existsSync(entryPath)) { return null @@ -281,7 +291,9 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { entryRelPath: sources.entryRelPath } writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker)) - // Replace any stale/partial dest, then publish the staging dir atomically. + // Replace any stale/partial dest, then publish atomically. Windows refuses to delete a running + // image, so a live daemon already hosted in THIS version's dir (same-version reinstall, or a dev + // channel reusing a version) throws here and materialization fails open to the install-dir host. rmSync(dest, { recursive: true, force: true }) renameSync(staging, dest) } catch { diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index 873c8bd3340..82bd1e65c9d 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -21,7 +21,7 @@ describe('getBranchConflictKindViaExec', () => { await expect(getBranchConflictKindViaExec(exec, 'feature/fix')).resolves.toBe('remote') expect(calls).toEqual([ - ['rev-parse', '--verify', 'refs/heads/feature/fix'], + ['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'], ['remote'], ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/foo/bar/feature/fix'], ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/feature/fix'] @@ -41,7 +41,10 @@ describe('getBranchConflictKindViaExec', () => { await expect( getBranchConflictKindViaExec(exec, 'feature/fix', 'origin/feature/fix') ).resolves.toBeNull() - expect(calls).toEqual([['rev-parse', '--verify', 'refs/heads/feature/fix'], ['remote']]) + expect(calls).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'], + ['remote'] + ]) }) it('keeps longest configured remote-name matching semantics', async () => { @@ -176,7 +179,7 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) ).resolves.toBeNull() expect(calls).toEqual([ - ['rev-parse', '--verify', 'refs/heads/feature'], + ['rev-parse', '--verify', '--quiet', 'refs/heads/feature'], ['remote'], ['cat-file', '--batch-check'] ]) @@ -254,3 +257,68 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) }) }) + +describe('branch conflict with existing-branch adoption', () => { + const absent = () => Object.assign(new Error('missing'), { code: 1, stderr: '' }) + + it('skips adoption and its commit probe for a proven missing local ref', async () => { + const exec = vi.fn(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + throw absent() + } + return { stdout: '' } + }) + const adopt = vi.fn(async () => false) + await expect( + getBranchConflictKindViaExec(exec, 'new', undefined, {}, undefined, adopt) + ).resolves.toBeNull() + expect(adopt).not.toHaveBeenCalled() + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('allows an existing branch without querying remote refs', async () => { + const exec = vi.fn(async () => ({ stdout: 'a'.repeat(40) })) + const adopt = vi.fn(async () => true) + await expect( + getBranchConflictKindViaExec(exec, 'existing', undefined, {}, undefined, adopt) + ).resolves.toBeNull() + expect(adopt).toHaveBeenCalledOnce() + expect(exec).toHaveBeenCalledOnce() + }) + + it('retains conflicts for refs whose objects cannot be adopted as commits', async () => { + const exec = vi.fn(async () => ({ stdout: 'a'.repeat(40) })) + const adopt = vi.fn(async () => false) + await expect( + getBranchConflictKindViaExec(exec, 'dangling', undefined, {}, undefined, adopt) + ).resolves.toBe('local') + expect(adopt).toHaveBeenCalledOnce() + expect(exec).toHaveBeenCalledTimes(2) + }) + + it.each([ + Object.assign(new Error('transport'), { code: 1, stderr: 'transport failed' }), + Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) + ])('still attempts adoption after an undecided ref probe: %s', async (error) => { + const exec = vi.fn(async () => { + throw error + }) + const adopt = vi.fn(async () => true) + await expect( + getBranchConflictKindViaExec(exec, 'existing', undefined, {}, undefined, adopt) + ).resolves.toBeNull() + expect(adopt).toHaveBeenCalledOnce() + }) + + it('rechecks a ref that disappeared while adoption was running', async () => { + const exec = vi + .fn() + .mockResolvedValueOnce({ stdout: 'a'.repeat(40) }) + .mockRejectedValueOnce(absent()) + .mockResolvedValueOnce({ stdout: '' }) + await expect( + getBranchConflictKindViaExec(exec, 'removed', undefined, {}, undefined, async () => false) + ).resolves.toBeNull() + expect(exec).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 5c6e03b94b0..f22fd97f99e 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -3,6 +3,7 @@ import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-re import { gitExecFileAsync } from './runner' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import { + isShowRefNoMatchError, probeAnyExactRef, probeAnyExactRefBatched, type ExactRefProbeExec, @@ -29,16 +30,17 @@ function canQueryRemoteBranchName(branchName: string): boolean { return !branchName.startsWith('-') && isSafeGitRefName(`refs/heads/${branchName}`) } -async function hasGitRefAsync( +async function probeLocalBranchRef( exec: ExactRefProbeExec, ref: string, options: ExactRefProbeExecOptions -): Promise { +): Promise<'present' | 'absent' | 'unknown'> { try { - const { stdout } = await runGit(exec, ['rev-parse', '--verify', ref], options) - return stdout.trim().length > 0 - } catch { - return false + // Quiet absence avoids retrying the WSL probe through a login shell. + const { stdout } = await runGit(exec, ['rev-parse', '--verify', '--quiet', ref], options) + return stdout.trim().length > 0 ? 'present' : 'unknown' + } catch (error) { + return isShowRefNoMatchError(error) ? 'absent' : 'unknown' } } @@ -105,7 +107,8 @@ export async function getBranchConflictKindViaExec( branchName: string, allowedBaseRef?: string, options: ExactRefProbeExecOptions = {}, - batchedExec?: ExactRefProbeStdinExec + batchedExec?: ExactRefProbeStdinExec, + allowLocalBranch?: () => Promise ): Promise { if (!canQueryRemoteBranchName(branchName)) { return null @@ -114,7 +117,16 @@ export async function getBranchConflictKindViaExec( // are quiet, so introducing a smaller implicit cap would only make a large // remote configuration look like a missing conflict. const probeOptions: ExactRefProbeExecOptions = options - if (await hasGitRefAsync(exec, `refs/heads/${branchName}`, probeOptions)) { + const localRef = `refs/heads/${branchName}` + let presence = await probeLocalBranchRef(exec, localRef, probeOptions) + if (allowLocalBranch && presence !== 'absent') { + if (await allowLocalBranch()) { + return null + } + // Adoption can span ref changes; preserve the fresh conflict check after it fails. + presence = await probeLocalBranchRef(exec, localRef, probeOptions) + } + if (presence === 'present') { return 'local' } @@ -142,7 +154,8 @@ export function getBranchConflictKind( path: string, branchName: string, allowedBaseRef?: string, - options: LocalGitExecOptions = {} + options: LocalGitExecOptions = {}, + allowLocalBranch?: () => Promise ): Promise { const execOptions = gitExecOptions(path, options) const runLocalGit = ( @@ -168,7 +181,8 @@ export function getBranchConflictKind( // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove. // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs // no fence; the capture wrapper preserves the payload's exit status either way. - (argv, commandOptions) => runLocalGit(argv, commandOptions, true) + (argv, commandOptions) => runLocalGit(argv, commandOptions, true), + allowLocalBranch ) } diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 284cda55718..e7ea423f78e 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,6 +17,7 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) +import { getBranchConflictKind } from './repo-branch-conflict' import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution' import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { @@ -584,6 +585,33 @@ describe('WSL direct Git reads', () => { }) }) + it('checks a missing branch conflict without retrying through a login shell', async () => { + await withPlatform('win32', async () => { + seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + execFileMock.mockImplementation((_command, args: string[], _options, callback) => { + const child = createMockChild() + queueMicrotask(() => { + const missingRef = args.join(' ').includes('rev-parse') + const quiet = args.includes('--quiet') + const code = missingRef ? (quiet ? 1 : 128) : 0 + callback?.( + code ? Object.assign(new Error('missing ref'), { code }) : null, + '', + missingRef && !quiet ? 'fatal: Needed a single revision' : '' + ) + child.emit('close', code, null) + }) + return child + }) + + await expect( + getBranchConflictKind(String.raw`\\wsl.localhost\Ubuntu\repo`, 'new-feature') + ).resolves.toBeNull() + expect(execFileMock).toHaveBeenCalledTimes(2) + expect(execFileMock.mock.calls[0]?.[1]).toContain('--quiet') + }) + }) + it('keeps the fast path when direct and login Git both report an expected failure', async () => { await withPlatform('win32', async () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index 276bf26e646..1ec81694d33 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -14,7 +14,10 @@ import { } from '../../../shared/git-status-line-stats-cache' import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path' import { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner' -import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection' +import { + findExistingWorktreeSymlinkPaths, + getSafeRelativePath +} from '../worktree-symlink-detection' import type { GetStatusOptions } from './get-status-options' import { statusReadLeaseOwner } from './git-read-cache-invalidation' import { detectConflictOperation } from './git-conflict-operation' @@ -89,8 +92,18 @@ async function dropSharedSymlinkUntrackedEntries( if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) { return } + const untrackedPaths = new Set( + entries.filter((entry) => entry.area === 'untracked').map((entry) => entry.path) + ) + const candidatePaths = sharedLinkPaths.filter((rawPath) => { + const path = getSafeRelativePath(rawPath) + return path.safe && untrackedPaths.has(path.rel) + }) + if (candidatePaths.length === 0) { + return + } const sharedLinks = new Set( - await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths, { + await findExistingWorktreeSymlinkPaths(worktreePath, candidatePaths, { wslDistro: options.wslDistro }) ) diff --git a/src/main/git/status-symlink-probe-budget.test.ts b/src/main/git/status-symlink-probe-budget.test.ts new file mode 100644 index 00000000000..b312048078b --- /dev/null +++ b/src/main/git/status-symlink-probe-budget.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' +import { getStatus } from './source-control/status-read' + +const { lstat, stream, conflict } = vi.hoisted(() => ({ + lstat: vi.fn(), + stream: vi.fn(), + conflict: vi.fn() +})) +vi.mock('node:fs/promises', () => ({ lstat })) +vi.mock('./source-control/git-conflict-operation', () => ({ detectConflictOperation: conflict })) +vi.mock('./runner', () => ({ + gitStreamStdout: stream, + gitOptionalLocksDisabledEnv: () => ({ GIT_OPTIONAL_LOCKS: '0' }) +})) + +beforeEach(() => { + vi.clearAllMocks() + conflict.mockResolvedValue(undefined) + lstat.mockResolvedValue({ isSymbolicLink: () => true }) + stream.mockImplementation(async (_args, options) => { + options.onStdout('? unrelated.txt\n') + return { stoppedEarly: false } + }) +}) + +function status(sharedLinkPaths: string[]) { + return getStatus('/repo', { sharedLinkPaths, includeLineStats: false }) +} + +describe('status shared symlink probe budget', () => { + it.each([1, 8, 32])( + 'does no unrelated symlink probes for %i configured paths over 100 refreshes', + async (count) => { + const paths = Array.from({ length: count }, (_, index) => `shared-${index}`) + for (let refresh = 0; refresh < 100; refresh++) { + expect((await status(paths)).entries).toEqual([ + { path: 'unrelated.txt', status: 'untracked', area: 'untracked' } + ]) + } + expect(lstat).not.toHaveBeenCalled() + expect(stream).toHaveBeenCalledTimes(100) + } + ) + + it('probes matching normalized paths, retaining duplicate probes and original order', async () => { + stream.mockImplementation(async (_args, options) => { + options.onStdout('? link\n? 日本 語\n? unrelated.txt\n') + return { stoppedEarly: false } + }) + const result = await status(['absent', ' /link ', '\\日本 語', 'link', '../link', 'C:link']) + expect(lstat.mock.calls.map(([path]) => path)).toEqual([ + resolve('/repo', 'link'), + resolve('/repo', '日本 語'), + resolve('/repo', 'link') + ]) + expect(result.entries.map((entry) => entry.path)).toEqual(['unrelated.txt']) + }) + + it('rechecks matching paths after the filesystem changes and preserves unreadable paths', async () => { + const paths = ['unrelated.txt'] + expect((await status(paths)).entries).toEqual([]) + lstat.mockResolvedValueOnce({ isSymbolicLink: () => false }) + expect((await status(paths)).entries).toHaveLength(1) + lstat.mockRejectedValueOnce(new Error('EACCES')) + expect((await status(paths)).entries).toHaveLength(1) + expect(lstat).toHaveBeenCalledTimes(3) + }) + + it('does not broaden exact path matching to descendants or case variants', async () => { + stream.mockImplementation(async (_args, options) => { + options.onStdout('? link/child\n? LINK\n') + return { stoppedEarly: false } + }) + expect((await status(['link'])).entries.map((entry) => entry.path)).toEqual([ + 'link/child', + 'LINK' + ]) + expect(lstat).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index 44394b7728d..ff44ca7ac6d 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -199,7 +199,7 @@ describe('addWorktree', () => { }) const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find( - ([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add' + ([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add') ) expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS }) expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0) @@ -214,7 +214,7 @@ describe('addWorktree', () => { }) const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find( - ([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add' + ([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add') ) expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 }) }) diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index dba4303647c..1ba2e6c16b8 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -1,5 +1,5 @@ // addWorktree: fast-forwarding the local base ref (reset --hard / update-ref) and its safety bailouts. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, @@ -32,11 +32,14 @@ import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() describe('addWorktree', () => { + afterEach(() => vi.restoreAllMocks()) const resolveCreationBaseConfigWrite = () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base } beforeEach(() => { + // These branch-safety assertions use POSIX argv; Windows flags have separate coverage. + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() diff --git a/src/main/git/worktree-add-local-base-suggestion.test.ts b/src/main/git/worktree-add-local-base-suggestion.test.ts index a65f77dd77d..3b449c339f6 100644 --- a/src/main/git/worktree-add-local-base-suggestion.test.ts +++ b/src/main/git/worktree-add-local-base-suggestion.test.ts @@ -1,5 +1,5 @@ // addWorktree: advisory local-base-ref update suggestions when the refresh setting is off. -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { gitExecFileAsyncMock, @@ -32,7 +32,10 @@ import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() describe('addWorktree', () => { + afterEach(() => vi.restoreAllMocks()) beforeEach(() => { + // These branch-safety assertions use POSIX argv; Windows flags have separate coverage. + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() translateWslOutputPathsMock.mockClear() diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index ea6ec704b46..380f3a3cc34 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -12,7 +12,7 @@ import { getLocalBaseRefUpdateSuggestionForWorktreeCreate, refreshLocalBaseRefForWorktreeCreate } from './worktree-base-refresh' -import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' import type { AddWorktreeOptions, AddWorktreeResult, @@ -23,6 +23,7 @@ import { bumpWorktreeScanGeneration } from './worktree-scan-cache' export type WorktreeAddBaseContext = AddWorktreeResult & { effectiveBase: string + effectiveBaseOid?: string } export async function resolveWorktreeAddBaseContext( @@ -31,9 +32,11 @@ export async function resolveWorktreeAddBaseContext( refreshLocalBaseRef: boolean, options: AddWorktreeOptions ): Promise { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) + let effectiveBaseOid: string | null = null + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, async (qualifiedRef) => { + effectiveBaseOid = await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options) + return effectiveBaseOid !== null + }) const localBaseRefRefresh = refreshLocalBaseRef ? await refreshLocalBaseRefForWorktreeCreate( repoPath, @@ -55,6 +58,10 @@ export async function resolveWorktreeAddBaseContext( : undefined return { effectiveBase, + // Refresh/suggestion work can span ref changes; only reuse the immediate resolution probe. + ...(!refreshLocalBaseRef && !options.suggestLocalBaseRefUpdate && effectiveBaseOid + ? { effectiveBaseOid } + : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) } diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index 63f8021a7ae..19c023a2403 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -1,8 +1,10 @@ import { execFileSync } from 'node:child_process' +import { existsSync, watch, type FSWatcher } from 'node:fs' import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as gitRunner from './runner' import { createWorktreePreparationLockReason, isWorktreeCreatePreparation, @@ -15,6 +17,13 @@ import { prepareWorktreeCreateCheckout } from './worktree-create-preparation' import { areWorktreePathsEqual } from './worktree-path-comparison' +import { + _resetPreparationPoolForTests, + listPreparations, + startPreparation, + takePreparation +} from '../worktree-create-preparation-pool' +import { hasPendingStalePreparationCleanup } from '../worktree-create-preparation-stale-cleanup' const tempRoots: string[] = [] @@ -46,6 +55,159 @@ afterEach(async () => { }) describe('prepared worktree creation with real Git', () => { + it('retains preparation ownership when the removal command cannot start', async () => { + const fixture = await createRepo() + const repoPath = await realpath(fixture.repoPath) + const root = await realpath(fixture.root) + const preparedPath = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY, 'owned-removal') + await mkdir(join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY), { recursive: true }) + const lockReason = createWorktreePreparationLockReason('removal-failure') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) + const original = gitRunner.gitExecFileAsync + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { + if (args.includes('remove') && args.includes(preparedPath)) { + return Promise.reject(new Error('injected removal launch failure')) + } + return original(args, options) + }) + try { + await expect(discardPreparedWorktree(repoPath, preparedPath)).rejects.toThrow( + 'injected removal launch failure' + ) + const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true }) + const prepared = remaining.find((worktree) => + areWorktreePathsEqual(worktree.path, preparedPath) + ) + expect(prepared).toBeDefined() + expect(prepared?.lockReason).toBe(lockReason) + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n') + } finally { + spy.mockRestore() + await discardPreparedWorktree(repoPath, preparedPath) + } + expect(existsSync(preparedPath)).toBe(false) + }) + + it('creates and finalizes while dead-owner reclamation is stalled', async () => { + const fixture = await createRepo() + const repoPath = await realpath(fixture.repoPath) + const root = await realpath(fixture.root) + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const stalePath = join(preparationRoot, '999999999-11111111-1111-4111-8111-111111111111') + await mkdir(preparationRoot, { recursive: true }) + await prepareWorktreeCreateCheckout( + repoPath, + stalePath, + 'main', + 'orca-create-preparation:v1:999999999:stale' + ) + let releaseRemoval!: () => void + const removalGate = new Promise((resolve) => { + releaseRemoval = resolve + }) + let markRemovalStarted!: () => void + const removalStarted = new Promise((resolve) => { + markRemovalStarted = resolve + }) + const original = gitRunner.gitExecFileAsync + const spy = vi + .spyOn(gitRunner, 'gitExecFileAsync') + .mockImplementation(async (args, options) => { + if (args.includes('remove') && args.includes(stalePath)) { + markRemovalStarted() + await removalGate + } + return original(args, options) + }) + try { + const preparing = startPreparation({ + repoPath, + workspaceRoot: root, + baseBranch: 'main', + canonicalBase: 'refs/heads/main', + options: {} + }) + await removalStarted + expect(hasPendingStalePreparationCleanup()).toBe(true) + await preparing + const [entry] = listPreparations() + expect(entry).toBeDefined() + takePreparation(entry) + const finalPath = join(root, 'fresh-worktree') + await finalizePreparedWorktree(repoPath, entry.preparedPath, finalPath, 'fresh', 'main') + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('fresh') + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n') + expect(existsSync(stalePath)).toBe(true) + expect(hasPendingStalePreparationCleanup()).toBe(true) + releaseRemoval() + await _resetPreparationPoolForTests() + expect(existsSync(stalePath)).toBe(false) + const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true }) + expect(remaining).toHaveLength(2) + expect(remaining.map((w) => w.path)).toEqual(expect.arrayContaining([repoPath, finalPath])) + expect(hasPendingStalePreparationCleanup()).toBe(false) + } finally { + releaseRemoval() + await _resetPreparationPoolForTests() + spy.mockRestore() + } + }) + + it('removes partial checkout files and registration after materialization is aborted', async () => { + const { repoPath, root } = await createRepo() + await Promise.all( + Array.from({ length: 1000 }, (_, index) => + writeFile( + join(repoPath, `payload-${index.toString().padStart(4, '0')}.txt`), + 'payload'.repeat(128) + ) + ) + ) + git(repoPath, ['add', '.']) + git(repoPath, ['commit', '--quiet', '-m', 'materialization fixture']) + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = join(preparationRoot, `${process.pid}-partial`) + await mkdir(preparationRoot, { recursive: true }) + const controller = new AbortController() + const original = gitRunner.gitExecFileAsync + let watcher: FSWatcher | undefined + let observedMaterialization = false + const calls: string[][] = [] + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { + calls.push([...args]) + if (args.includes('reset')) { + watcher = watch(preparedPath, (_event, filename) => { + // Only the reset writes here, so an event without a filename is still materialization. + if (filename === null || filename.toString().startsWith('payload-')) { + observedMaterialization = true + watcher?.close() + controller.abort() + } + }) + } + return original(args, options) + }) + try { + await expect( + prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('partial-test'), + { signal: controller.signal } + ) + ).rejects.toThrow() + expect(observedMaterialization).toBe(true) + expect(calls.some((args) => args[args.indexOf('worktree') + 1] === 'lock')).toBe(false) + expect(existsSync(preparedPath)).toBe(false) + expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) + } finally { + watcher?.close() + spy.mockRestore() + } + }) + it('cleans up when the create signal is canceled', async () => { const { repoPath, root } = await createRepo() const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) @@ -117,6 +279,58 @@ describe('prepared worktree creation with real Git', () => { ) }) + it.each(['before reset', 'after reset'])( + 'finalizes refreshed content when the base moves %s', + async (when) => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY, 'fetch-overlap') + const finalPath = join(root, 'final-overlap') + await mkdir(join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY), { recursive: true }) + const original = git(repoPath, ['rev-parse', 'HEAD']) + await writeFile(join(repoPath, 'version.txt'), 'refreshed\n') + git(repoPath, ['commit', '-am', 'remote update']) + const refreshed = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', original]) + const exec = gitRunner.gitExecFileAsync + let moved = false + const spy = vi + .spyOn(gitRunner, 'gitExecFileAsync') + .mockImplementation(async (args, options) => { + if (!moved && args.includes('reset') && when === 'before reset') { + git(repoPath, ['update-ref', 'refs/remotes/origin/main', refreshed]) + moved = true + } + const result = await exec(args, options) + if (!moved && args.includes('reset') && when === 'after reset') { + git(repoPath, ['update-ref', 'refs/remotes/origin/main', refreshed]) + moved = true + } + return result + }) + try { + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/remotes/origin/main', + createWorktreePreparationLockReason('fetch-overlap') + ) + expect(moved).toBe(true) + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/overlap', + 'refs/remotes/origin/main' + ) + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(refreshed) + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('refreshed\n') + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + } finally { + spy.mockRestore() + } + } + ) + it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => { const { repoPath, root } = await createRepo() const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) diff --git a/src/main/git/worktree-create-preparation-real-wsl.test.ts b/src/main/git/worktree-create-preparation-real-wsl.test.ts new file mode 100644 index 00000000000..8ccaf3c23bf --- /dev/null +++ b/src/main/git/worktree-create-preparation-real-wsl.test.ts @@ -0,0 +1,77 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import { gitExecFileAsync } from './runner' +import { + discardPreparedWorktree, + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' + +// Opt in on Windows with a running distro; all Git commands use the production WSL router. +const wslDistro = process.env.ORCA_TEST_WSL_DISTRO + +it.skipIf(process.platform !== 'win32' || !wslDistro)( + 'prepares, retargets, moves and cleans up a real WSL checkout from Windows', + async () => { + const fixtureParent = process.env.ORCA_TEST_WSL_ROOT ?? `\\\\wsl.localhost\\${wslDistro}\\tmp` + const root = await mkdtemp(join(fixtureParent, 'orca-create-route-')) + const repoPath = join(root, 'repo') + const preparedPath = join(root, 'prepared checkout') + const finalPath = join(root, 'final checkout') + const options = { wslDistro, timeout: 60_000 } + const git = async (cwd: string, args: string[]): Promise => + (await gitExecFileAsync(args, { cwd, ...options })).stdout.trim() + + try { + await mkdir(repoPath) + await git(repoPath, ['init', '--quiet']) + expect(await git(repoPath, ['rev-parse', '--show-toplevel'])).toMatch(/^\/(?!\/)/) + await git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await git(repoPath, ['config', 'user.name', 'Test User']) + await git(repoPath, ['config', 'user.email', 'test@example.com']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + await git(repoPath, ['add', 'version.txt']) + await git(repoPath, ['commit', '--quiet', '-m', 'initial']) + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('real-wsl-test'), + options + ) + expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain( + 'locked orca-create-preparation:v1:' + ) + + await writeFile(join(repoPath, 'version.txt'), 'two\n') + await git(repoPath, ['commit', '--quiet', '-am', 'advance base']) + const target = await git(repoPath, ['rev-parse', 'HEAD']) + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/routed', + 'main', + false, + options + ) + expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target) + expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed') + expect(await git(finalPath, ['status', '--porcelain'])).toBe('') + expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('two\n') + expect(await git(finalPath, ['config', '--get', 'branch.feature/routed.base'])).toBe( + 'refs/heads/main' + ) + expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ') + await discardPreparedWorktree(repoPath, finalPath, options) + expect( + (await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm) + ).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + } + }, + 120_000 +) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index b60dc01ec33..0f27f045287 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -45,16 +45,16 @@ async function performDiscardPreparedWorktree( timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } try { + // Preserve the ownership lock if removal cannot start; Git 2.25 supports locked removal. await gitExecFileAsync( - [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], - cleanupGitOptions - ) - } catch { - // It may be unlocked already or only partially registered. - } - try { - await gitExecFileAsync( - [...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath], + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'remove', + '--force', + '--force', + worktreePath + ], cleanupGitOptions ) } finally { @@ -195,25 +195,38 @@ export async function finalizePreparedWorktree( } try { return await runWithGitReadCacheInvalidation(async () => { - const baseContext = await resolveWorktreeAddBaseContext( - repoPath, - baseBranch, - refreshLocalBaseRef, - finalizeGitOptions - ) - const [targetHeadResult, preparedHeadResult] = await Promise.all([ - gitExecFileAsync( - ['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`], - gitExecOptions(repoPath, finalizeGitOptions) - ), + const [targetResult, preparedResult] = await Promise.allSettled([ + (async () => { + const baseContext = await resolveWorktreeAddBaseContext( + repoPath, + baseBranch, + refreshLocalBaseRef, + finalizeGitOptions + ) + const targetHead = + baseContext.effectiveBaseOid ?? + ( + await gitExecFileAsync( + ['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`], + gitExecOptions(repoPath, finalizeGitOptions) + ) + ).stdout.trim() + return { baseContext, targetHead } + })(), gitExecFileAsync( ['rev-parse', '--verify', 'HEAD'], gitExecOptions(preparedPath, finalizeGitOptions) ) ]) - const { stdout: targetHeadOutput } = targetHeadResult - const targetHead = targetHeadOutput.trim() - const { stdout: preparedHeadOutput } = preparedHeadResult + // Settle both reads before failure cleanup can remove the prepared checkout. + if (targetResult.status === 'rejected') { + throw targetResult.reason + } + if (preparedResult.status === 'rejected') { + throw preparedResult.reason + } + const { baseContext, targetHead } = targetResult.value + const preparedHeadOutput = preparedResult.value.stdout if (preparedHeadOutput.trim() !== targetHead) { await gitExecFileAsync( [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], diff --git a/src/main/git/worktree-preparation-base-oid.test.ts b/src/main/git/worktree-preparation-base-oid.test.ts new file mode 100644 index 00000000000..5864b9d305a --- /dev/null +++ b/src/main/git/worktree-preparation-base-oid.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const gitExec = vi.hoisted(() => vi.fn()) +vi.mock('./runner', () => ({ gitExecFileAsync: gitExec })) +vi.mock('./worktree-base-refresh', () => ({ + refreshLocalBaseRefForWorktreeCreate: vi.fn(), + getLocalBaseRefUpdateSuggestionForWorktreeCreate: vi.fn() +})) +vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() })) +vi.mock('./wsl-linked-worktree-git-routing', () => ({ + invalidateWslLinkedWorktreeGitRouting: vi.fn() +})) + +import { finalizePreparedWorktree } from './worktree-create-preparation' + +const originalOid = '1'.repeat(40) +const refreshedOid = '2'.repeat(40) + +beforeEach(() => { + gitExec.mockReset().mockImplementation(async (args: string[]) => ({ + stdout: + args[0] === 'rev-parse' + ? args.includes('--quiet') || args.at(-1) === 'HEAD' + ? originalOid + : refreshedOid + : '' + })) +}) + +it('reuses the current base-resolution oid and preserves WSL routing', async () => { + await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, { + wslDistro: 'Ubuntu', + timeout: 8000 + }) + const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse') + expect(revisions.map(([args]) => args)).toEqual([ + ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], + ['rev-parse', '--verify', 'HEAD'] + ]) + expect(gitExec.mock.calls.find(([args]) => args.includes('checkout'))?.[0]).toContain(originalOid) + expect(gitExec.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + for (const [, options] of gitExec.mock.calls) { + expect(options).toMatchObject({ wslDistro: 'Ubuntu', timeout: 8000 }) + } +}) + +it.each([ + { base: 'refs/heads/main', refresh: false, options: {} }, + { base: 'main', refresh: true, options: {} }, + { base: 'main', refresh: false, options: { suggestLocalBaseRefUpdate: true } } +])('re-reads the target for $base, refresh=$refresh, options=$options', async (test) => { + await finalizePreparedWorktree( + '/repo', + '/prepared', + '/final', + 'feature', + test.base, + test.refresh, + test.options + ) + expect(gitExec).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'refs/heads/main^{commit}'], + expect.objectContaining({ cwd: '/repo' }) + ) + expect(gitExec.mock.calls.find(([args]) => args.includes('reset'))?.[0]).toContain(refreshedOid) + expect(gitExec.mock.calls.find(([args]) => args.includes('checkout'))?.[0]).toContain( + refreshedOid + ) +}) + +it('starts both independent probes before either resolves and settles them before failure', async () => { + let resolveBase!: (value: { stdout: string }) => void + let rejectPrepared!: (reason: Error) => void + gitExec.mockImplementation((args: string[]) => { + if (args.includes('--quiet')) { + return new Promise((resolve) => (resolveBase = resolve)) + } + if (args.at(-1) === 'HEAD') { + return new Promise((_, reject) => (rejectPrepared = reject)) + } + return Promise.resolve({ stdout: '' }) + }) + let settled = false + const error = new Error('prepared HEAD unreadable') + const result = finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main') + const checked = expect(result).rejects.toBe(error) + void result.then( + () => (settled = true), + () => (settled = true) + ) + await vi.waitFor(() => expect(gitExec).toHaveBeenCalledTimes(2)) + rejectPrepared(error) + await Promise.resolve() + expect(settled).toBe(false) + resolveBase({ stdout: originalOid }) + await checked + expect(gitExec.mock.calls.some(([args]) => args.includes('move'))).toBe(false) +}) diff --git a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts new file mode 100644 index 00000000000..e2750df839d --- /dev/null +++ b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts @@ -0,0 +1,158 @@ +// Opt in: ORCA_WORKTREE_PREPARATION_CANCEL_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/git/worktree-preparation-cancel-latency.bench.test.ts +// +// Measures the create-side cost of an obsolete preparation: the wall time of a fresh checkout +// (the next Create's critical path) while an evicted preparation's checkout is either left running +// (main before #18951) or aborted (after). Same code, same fixture; only the abort differs. +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + createWorktreePreparationLockReason, + WORKTREE_CREATE_PREPARATION_DIRECTORY +} from '../../shared/worktree/create-preparation' +import { + discardPreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' + +const describeBench = process.env.ORCA_WORKTREE_PREPARATION_CANCEL_BENCH ? describe : describe.skip +const FILE_COUNT = Number(process.env.ORCA_WORKTREE_PREPARATION_CANCEL_BENCH_FILES ?? 6000) +const FILE_BYTES = 48 * 1024 +const TRIALS = Number(process.env.ORCA_WORKTREE_PREPARATION_CANCEL_BENCH_TRIALS ?? 5) +const OBSOLETE_COUNTS = [1, 3] +const RESULT_PATH = process.env.ORCA_WORKTREE_PREPARATION_CANCEL_BENCH_RESULT + +type Variant = 'running' | 'aborted' +type Sample = { variant: Variant; obsolete: number; freshCheckoutMs: number } + +let root = '' +let repoPath = '' +let preparationRoot = '' +let sequence = 0 + +function git(cwd: string, args: string[]): void { + execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }) +} + +function nextPreparedPath(label: string): string { + sequence += 1 + return join(preparationRoot, `${process.pid}-${label}-${sequence}`) +} + +function checkout(preparedPath: string, signal?: AbortSignal): Promise { + return prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason(`bench-${sequence}`), + signal ? { signal } : {} + ) +} + +async function runTrial(variant: Variant, obsolete: number): Promise { + const controllers = Array.from({ length: obsolete }, () => new AbortController()) + const obsoletePaths = controllers.map(() => nextPreparedPath('obsolete')) + const obsoleteWork = obsoletePaths.map((path, index) => + checkout(path, controllers[index].signal).catch(() => {}) + ) + if (variant === 'aborted') { + // Eviction aborts in the same turn the incoming preparation is armed, so abort before the + // fresh checkout starts. + controllers.forEach((controller) => controller.abort()) + } + const freshPath = nextPreparedPath('fresh') + const started = performance.now() + await checkout(freshPath) + const freshCheckoutMs = performance.now() - started + await Promise.all(obsoleteWork) + await Promise.all( + [...obsoletePaths, freshPath].map((path) => + discardPreparedWorktree(repoPath, path).catch(() => {}) + ) + ) + return { variant, obsolete, freshCheckoutMs } +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2 +} + +describeBench('obsolete preparation cancellation latency', () => { + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-preparation-cancel-bench-')) + repoPath = join(root, 'repo') + preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + await mkdir(preparationRoot, { recursive: true }) + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'bench@example.com']) + git(repoPath, ['config', 'user.name', 'Bench']) + git(repoPath, ['config', 'core.autocrlf', 'false']) + // Unique content per file so the object store cannot dedupe the materialization work. + for (let batch = 0; batch < FILE_COUNT; batch += 500) { + await Promise.all( + Array.from({ length: Math.min(500, FILE_COUNT - batch) }, (_, offset) => { + const index = batch + offset + return writeFile( + join(repoPath, `payload-${index.toString().padStart(5, '0')}.txt`), + `${index}\n`.repeat(Math.ceil(FILE_BYTES / `${index}\n`.length)) + ) + }) + ) + } + git(repoPath, ['add', '.']) + git(repoPath, ['commit', '--quiet', '-m', 'bench fixture']) + }, 600_000) + + afterAll(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('reports fresh checkout wall time with obsolete checkouts running vs aborted', async () => { + // Warm the object store and page cache once so the first variant is not penalised. + const warm = nextPreparedPath('warm') + await checkout(warm) + await discardPreparedWorktree(repoPath, warm) + + const samples: Sample[] = [] + for (const obsolete of OBSOLETE_COUNTS) { + for (let trial = 0; trial < TRIALS; trial += 1) { + // Alternate order so drift in cache or thermal state does not favour one variant. + const order: Variant[] = trial % 2 ? ['aborted', 'running'] : ['running', 'aborted'] + for (const variant of order) { + samples.push(await runTrial(variant, obsolete)) + } + } + } + const summary = OBSOLETE_COUNTS.map((obsolete) => { + const pick = (variant: Variant): number[] => + samples + .filter((sample) => sample.variant === variant && sample.obsolete === obsolete) + .map((sample) => sample.freshCheckoutMs) + const running = median(pick('running')) + const aborted = median(pick('aborted')) + return { + obsolete, + trials: TRIALS, + freshCheckoutMedianMs: { obsoleteRunning: running, obsoleteAborted: aborted }, + speedup: running / aborted + } + }) + const report = JSON.stringify( + { fixture: { files: FILE_COUNT, bytesPerFile: FILE_BYTES }, samples, summary }, + null, + 2 + ) + console.log(report) + if (RESULT_PATH) { + await writeFile(RESULT_PATH, `${report}\n`) + } + expect(existsSync(preparationRoot)).toBe(true) + }, 900_000) +}) diff --git a/src/main/github/project-view/project-view-field-normalization.ts b/src/main/github/project-view/project-view-field-normalization.ts index 1441a999809..ab41b812e08 100644 --- a/src/main/github/project-view/project-view-field-normalization.ts +++ b/src/main/github/project-view/project-view-field-normalization.ts @@ -145,7 +145,8 @@ export function normalizeFieldValue( iterationId: raw.iterationId, title: raw.title ?? '', startDate: raw.startDate ?? '', - duration: typeof raw.duration === 'number' ? raw.duration : 0 + duration: typeof raw.duration === 'number' ? raw.duration : 0, + ...(typeof raw.field.name === 'string' ? { fieldName: raw.field.name } : {}) } case 'ProjectV2ItemFieldTextValue': return { kind: 'text', fieldId, text: raw.text ?? '' } @@ -155,7 +156,12 @@ export function normalizeFieldValue( } return { kind: 'number', fieldId, number: raw.number } case 'ProjectV2ItemFieldDateValue': - return { kind: 'date', fieldId, date: raw.date ?? '' } + return { + kind: 'date', + fieldId, + date: raw.date ?? '', + ...(typeof raw.field.name === 'string' ? { fieldName: raw.field.name } : {}) + } case 'ProjectV2ItemFieldLabelValue': { const labels = (raw.labels?.nodes ?? []) .map(normalizeLabel) diff --git a/src/main/github/project-view/project-view-table.test.ts b/src/main/github/project-view/project-view-table.test.ts new file mode 100644 index 00000000000..b51a08d4bf0 --- /dev/null +++ b/src/main/github/project-view/project-view-table.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fetchProjectViewsPage, type RawProjectView } from './project-view-config' +import type * as ProjectViewConfig from './project-view-config' +import { fetchAllItems, fetchItemsCountOnly } from './project-view-items' +import { getProjectViewTable } from './project-view-table' + +vi.mock('./project-view-config', async (importOriginal) => ({ + ...(await importOriginal()), + fetchProjectViewsPage: vi.fn() +})) +vi.mock('./project-view-items', () => ({ + fetchAllItems: vi.fn(), + fetchItemsCountOnly: vi.fn() +})) + +const args = { + owner: 'acme', + ownerType: 'organization', + projectNumber: 1, + host: 'github.acme.test' +} as const +const view = (id: string, layout: string): RawProjectView => ({ + id, + number: 1, + name: id, + layout, + filter: 'status:open', + fields: { nodes: [] }, + groupByFields: { nodes: [] }, + sortByFields: { nodes: [] } +}) +function page(views: RawProjectView[], hasNextPage = false) { + return { + ok: true as const, + project: { id: 'project', title: 'Plan', url: 'https://github.acme.test/orgs/acme/projects/1' }, + views, + hasNextPage, + endCursor: hasNextPage ? 'next' : null + } +} + +beforeEach(() => { + vi.resetAllMocks() + vi.mocked(fetchAllItems).mockResolvedValue({ + ok: true, + rows: [], + totalCount: 0, + parentFieldDropped: false + }) + vi.mocked(fetchItemsCountOnly).mockResolvedValue(12) +}) + +describe('project view layout selection', () => { + it('fetches roadmap items with the selected host and filter', async () => { + vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('roadmap', 'ROADMAP_LAYOUT')])) + const result = await getProjectViewTable({ ...args, viewId: 'roadmap' }) + expect(result).toMatchObject({ ok: true, data: { selectedView: { layout: 'ROADMAP_LAYOUT' } } }) + expect(fetchAllItems).toHaveBeenCalledWith({ ...args, query: 'status:open' }) + expect(fetchItemsCountOnly).not.toHaveBeenCalled() + }) + + it('defaults to a roadmap when no table exists across all view pages', async () => { + vi.mocked(fetchProjectViewsPage) + .mockResolvedValueOnce(page([view('roadmap', 'ROADMAP_LAYOUT')], true)) + .mockResolvedValueOnce(page([view('board', 'BOARD_LAYOUT')])) + expect(await getProjectViewTable(args)).toMatchObject({ + ok: true, + data: { selectedView: { id: 'roadmap' } } + }) + expect(fetchProjectViewsPage).toHaveBeenLastCalledWith({ ...args, after: 'next' }) + }) + + it('prefers a table on a later page over an earlier roadmap', async () => { + vi.mocked(fetchProjectViewsPage) + .mockResolvedValueOnce(page([view('roadmap', 'ROADMAP_LAYOUT')], true)) + .mockResolvedValueOnce(page([view('table', 'TABLE_LAYOUT')])) + expect(await getProjectViewTable(args)).toMatchObject({ + ok: true, + data: { selectedView: { id: 'table' } } + }) + }) + + it('does not substitute a roadmap for a missing explicit selection', async () => { + vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('roadmap', 'ROADMAP_LAYOUT')])) + expect(await getProjectViewTable({ ...args, viewId: 'missing' })).toMatchObject({ + ok: false, + error: { type: 'not_found' } + }) + expect(fetchAllItems).not.toHaveBeenCalled() + }) + + it.each(['BOARD_LAYOUT', 'FUTURE_LAYOUT'])( + 'rejects %s without fetching items', + async (layout) => { + vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('unsupported', layout)])) + expect( + await getProjectViewTable({ ...args, viewId: 'unsupported', queryOverride: '' }) + ).toMatchObject({ + ok: false, + error: { type: 'unsupported_layout' }, + totalCount: 12 + }) + expect(fetchAllItems).not.toHaveBeenCalled() + expect(fetchItemsCountOnly).toHaveBeenCalledWith({ ...args, query: '' }) + } + ) +}) diff --git a/src/main/github/project-view/project-view-table.ts b/src/main/github/project-view/project-view-table.ts index 042aeb333cf..bb580cb78e0 100644 --- a/src/main/github/project-view/project-view-table.ts +++ b/src/main/github/project-view/project-view-table.ts @@ -84,6 +84,14 @@ export async function getProjectViewTable( if (!project) { return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } } + const noSelector = + args.viewId === undefined && args.viewNumber === undefined && args.viewName === undefined + if (!selectedRaw && noSelector) { + // Why: `matchesSelector` only defaults to a table view, so a project whose + // views are all roadmaps resolved to nothing even though we can now render + // one. Table stays the preferred default; this is the empty-handed case. + selectedRaw = viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ?? null + } if (!selectedRaw) { return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } } } @@ -109,8 +117,10 @@ export async function getProjectViewTable( const effectiveQuery = typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter - // Unsupported layout: skip item pagination; best-effort count-only query. - if (selectedView.layout !== 'TABLE_LAYOUT') { + // Why: roadmaps read the same item stream as a table — only the renderer + // differs. Allowlist, not `=== 'BOARD_LAYOUT'`: raw.layout is cast unchecked, + // so a future GitHub layout must reject cleanly, not render as a table. + if (selectedView.layout !== 'TABLE_LAYOUT' && selectedView.layout !== 'ROADMAP_LAYOUT') { const count = await fetchItemsCountOnly({ owner: args.owner, ownerType: args.ownerType, @@ -122,7 +132,7 @@ export async function getProjectViewTable( ok: false, error: { type: 'unsupported_layout', - message: `Orca only renders table views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.` + message: `Orca renders table and roadmap views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.` }, ...(typeof count === 'number' ? { totalCount: count } : {}) } diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index ce10b3556fc..9bead362816 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -97,6 +97,14 @@ function makeStore(enabled = true) { } } +// These cases exercise foreground behavior against Electron mocks. +beforeEach(() => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined) + vi.stubEnv('ORCA_E2E_HEADLESS', undefined) + vi.stubEnv('ORCA_E2E_HEADFUL', undefined) +}) +afterEach(() => vi.unstubAllEnvs()) + describe('registerDashboardPopoutHandlers', () => { let store: ReturnType diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index da54799cad1..8e930dbf8b1 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -1,3 +1,4 @@ +import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import { buildGitGrepArgs, @@ -39,7 +40,7 @@ export async function searchWithGitGrep( return new Promise((resolve) => { const matchRegex = buildSubmatchRegex(args.query, args) const acc = createAccumulator() - let stdoutBuffer = '' + const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) let done = false let killTimeout: ReturnType @@ -49,6 +50,7 @@ export async function searchWithGitGrep( return } done = true + lines.clear() clearTimeout(killTimeout) // Why: child.kill() is advisory. If git ignores it, detach our // closures so repeated fallback searches do not retain old scans. @@ -67,12 +69,7 @@ export async function searchWithGitGrep( } function handleStdoutData(chunk: string): void { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const l of lines) { - processLine(l) - } + lines.push(chunk, processLine) } function handleStderrData(): void { @@ -84,8 +81,9 @@ export async function searchWithGitGrep( } function handleClose(): void { - if (stdoutBuffer) { - processLine(stdoutBuffer) + const tail = lines.finish() + if (tail !== null) { + processLine(tail) } resolveOnce() } diff --git a/src/main/ipc/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem-watcher-local-events.test.ts index e08145e7ad2..1907383bd6b 100644 --- a/src/main/ipc/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem-watcher-local-events.test.ts @@ -12,6 +12,7 @@ vi.mock('fs/promises', () => ({ stat: statMock })) vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: subscribeMock })) import { createLocalWatcher } from './filesystem-watcher-local-events' +import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control' function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -170,6 +171,35 @@ describe('local filesystem watcher flush serialization', () => { ) }) + it('starts no further stats when a full inflight batch is cancelled', async () => { + const eventCount = 5_000 + const pendingStats = deferred<{ isDirectory: () => boolean }>() + statMock.mockReturnValue(pendingStats.promise) + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) + + watcherCallback?.( + null, + Array.from({ length: eventCount }, (_, index) => ({ + type: 'update' as const, + path: `/repo/file-${index}.ts` + })) + ) + vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS) + await flushMicrotasks() + expect(statMock).toHaveBeenCalledTimes(8) + + cancelLocalBatchFlush(root) + pendingStats.resolve({ isDirectory: () => false }) + for (let i = 0; i < eventCount * 4 && root.batch.flushInFlight; i++) { + await Promise.resolve() + } + + expect(root.batch.flushInFlight).toBe(false) + expect(statMock).toHaveBeenCalledTimes(8) + expect(sender.send).not.toHaveBeenCalled() + }) + it('leaves an open debounce window to the armed timer instead of draining early', async () => { const firstStat = deferred<{ isDirectory: () => boolean }>() const secondStat = deferred<{ isDirectory: () => boolean }>() diff --git a/src/main/ipc/filesystem-watcher-local-events.ts b/src/main/ipc/filesystem-watcher-local-events.ts index 57ef7da4e1f..9f31adde4bd 100644 --- a/src/main/ipc/filesystem-watcher-local-events.ts +++ b/src/main/ipc/filesystem-watcher-local-events.ts @@ -139,7 +139,10 @@ async function flushBatch(root: WatchedRoot): Promise { DIRECTORY_STAT_CONCURRENCY, async (evt) => { // Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache. - const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path) + const isDirectory = + root.batch.cancelled || evt.type === 'delete' + ? undefined + : await tryStatIsDirectory(evt.path) return { kind: evt.type, diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 77a26c1e58d..2facbbfb445 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -1,3 +1,4 @@ +import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines' import { ipcMain } from 'electron' import type { ChildProcess } from 'node:child_process' import type { SearchOptions, SearchResult } from '../../../shared/code-search-types' @@ -68,7 +69,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } const acc = createAccumulator() - let stdoutBuffer = '' + const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -88,6 +89,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte if (activeTextSearches.get(searchKey) === child) { activeTextSearches.delete(searchKey) } + lines.clear() clearTimeout(killTimeout) // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. child?.stdout?.off('data', handleStdoutData) @@ -121,12 +123,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte activeTextSearches.set(searchKey, nextChild) const handleStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } + lines.push(chunk, processLine) } const handleStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. @@ -150,8 +147,9 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte resolveWithoutRipgrep() return } - if (stdoutBuffer) { - processLine(stdoutBuffer) + const tail = lines.finish() + if (tail !== null) { + processLine(tail) } resolveOnce() } diff --git a/src/main/ipc/notifications-retention-lifecycle.test.ts b/src/main/ipc/notifications-retention-lifecycle.test.ts index 278ca8f13ae..9705cf85a58 100644 --- a/src/main/ipc/notifications-retention-lifecycle.test.ts +++ b/src/main/ipc/notifications-retention-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getAllWindowsMock, @@ -30,6 +30,14 @@ vi.mock('../tray/system-tray', async () => import { registerNotificationHandlers } from './notifications' +// These cases exercise foreground behavior against Electron mocks. +beforeEach(() => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined) + vi.stubEnv('ORCA_E2E_HEADLESS', undefined) + vi.stubEnv('ORCA_E2E_HEADFUL', undefined) +}) +afterEach(() => vi.unstubAllEnvs()) + describe('registerNotificationHandlers', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/main/ipc/worktree-logic-wsl.test.ts b/src/main/ipc/worktree-logic-wsl.test.ts index c30c387263e..c21c20e0050 100644 --- a/src/main/ipc/worktree-logic-wsl.test.ts +++ b/src/main/ipc/worktree-logic-wsl.test.ts @@ -16,6 +16,7 @@ vi.mock('../wsl', () => ({ import { computeWorktreePath, computeWorktreePathAsync, + computeWorkspaceRootAsync, getWorktreePathSettings } from './worktree-logic' import { @@ -32,6 +33,26 @@ describe('computeWorktreePath WSL layout', () => { parseWslPathMock.mockReset() }) + it('reuses an asynchronously resolved root for every name candidate without a sync probe', async () => { + parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/jin/repo' }) + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\jin\repo` + const home = String.raw`\\wsl.localhost\Ubuntu\home\jin` + const settings = { workspaceDir: 'C:\\workspaces', nestWorkspaces: true } + let resolveHome!: (home: string) => void + getWslHomeAsyncMock.mockReturnValue(new Promise((resolve) => (resolveHome = resolve))) + const pendingRoot = computeWorkspaceRootAsync(repoPath, settings) + expect(getWslHomeMock).not.toHaveBeenCalled() + resolveHome(home) + const root = await pendingRoot + for (const name of ['feature', 'feature-2', 'feature-3']) { + expect(computeWorktreePath(name, repoPath, settings, root)).toBe( + win32.join(home, 'orca', 'workspaces', 'repo', name) + ) + } + expect(getWslHomeAsyncMock).toHaveBeenCalledExactlyOnceWith('Ubuntu') + expect(getWslHomeMock).not.toHaveBeenCalled() + }) + it('places WSL repo worktrees under the distro home workspace root', () => { parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 7a8fe175c89..744572f6e37 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -103,12 +103,13 @@ export function ensurePathWithinWorkspace(targetPath: string, workspaceDir: stri export function computeWorktreePath( sanitizedName: string, repoPath: string, - settings: WorktreePathSettings + settings: WorktreePathSettings, + workspaceRoot?: string ): string { return computeWorktreePathFromWorkspaceRoot( sanitizedName, repoPath, - computeWorkspaceRoot(repoPath, settings), + workspaceRoot ?? computeWorkspaceRoot(repoPath, settings), settings.nestWorkspaces ) } @@ -130,7 +131,7 @@ function computeWorktreePathFromWorkspaceRoot( } /** Async twin of computeWorktreePath. Same result; resolves the WSL home without blocking the main - * thread, so callers off the create path never freeze the app on a stopped distro. */ + * thread, so callers never freeze the app on a stopped distro. */ export async function computeWorktreePathAsync( sanitizedName: string, repoPath: string, @@ -147,7 +148,7 @@ export async function computeWorktreePathAsync( /** Async twin of computeWorkspaceRoot. Same result; the WSL home probe spawns `wsl.exe`, so * background preparation uses this variant rather than blocking the Electron main thread for up * to the probe timeout. The sync twin below still serves callers that cannot await (allowed-roots - * resolution, the create click, CLI create, watch targets, worktree trash). */ + * resolution, CLI create, watch targets, worktree trash). */ export async function computeWorkspaceRootAsync( repoPath: string, settings: { workspaceDir: string; wslMirrorDistro?: string } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 27eaa9264db..ef65f2fcb53 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -87,7 +87,7 @@ import { computeValidatedBranchName, computeWorktreePath, computeRemoteWorktreePath, - computeWorkspaceRoot, + computeWorkspaceRootAsync, ensurePathWithinWorkspace, getWorktreeCreationLayout, getWorktreePathSettings, @@ -2444,7 +2444,7 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } - const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) + const workspaceRoot = await computeWorkspaceRootAsync(repo.path, worktreePathSettings) // Why: this validation doesn't depend on remote refs, so it can overlap a required remote-tracking base refresh. const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup @@ -2530,19 +2530,33 @@ export async function createLocalWorktree( username, localWorktreeGitOptions ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - localWorktreeGitOptions - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. - selectedExistingLocalBranchName = branchName + const tryExistingBranch = async (): Promise => { + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions + ) + return checkoutExistingBranch } + // Explicit branch selections retain the adoption-first path. + const preferExistingBranch = Boolean( + args.branchNameOverride || selectedExistingLocalBranchName + ) + checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch()) lastBranchConflictKind = checkoutExistingBranch ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) + : await getBranchConflictKind( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions, + preferExistingBranch ? undefined : tryExistingBranch + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Path retries must retain the adopted branch. + selectedExistingLocalBranchName = branchName + } const allowedPushTargetRemoteConflict = lastBranchConflictKind && isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) @@ -2604,7 +2618,7 @@ export async function createLocalWorktree( } worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot), workspaceRoot ) if (existsSync(worktreePath)) { @@ -3010,6 +3024,8 @@ export async function createLocalWorktree( } }) + // Startup resolves the new id before lifecycle notifications invalidate runtime caches. + runtime?.invalidateWorktreeCatalog?.(repo.id) const stagedStartup = await timing.time('spawn_startup_terminal', () => spawnLocalStartupAndSetupTerminals({ runtime, diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index abc711bbd9b..fc57c6969b6 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' +import { computeWorkspaceRootAsync } from './worktree-logic' +import type * as WorktreeLogic from './worktree-logic' import { listWorktreesMock, describeCreatedWorktreeMock, @@ -78,11 +80,13 @@ vi.mock('../setup-hook-env-vars', async (importOriginal) => (await importOriginal()) as Record ) ) -vi.mock('./worktree-logic', async (importOriginal) => - (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( - (await importOriginal()) as Record - ) -) +vi.mock('./worktree-logic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(actual), + computeWorkspaceRootAsync: vi.fn(actual.computeWorkspaceRootAsync) + } +}) vi.mock('../terminal-history-deletion', async () => (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() ) @@ -409,15 +413,23 @@ describe('registerWorktreeHandlers', () => { } ]) - await handlers['worktrees:create'](null, { + const root = Promise.withResolvers() + vi.mocked(computeWorkspaceRootAsync).mockReturnValueOnce(root.promise) + const create = handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'feature' }) - expect(computeWorktreePathMock).toHaveBeenCalledWith('feature', '/workspace/repo', { - nestWorkspaces: false, - workspaceDir: '../worktrees' - }) + await vi.waitFor(() => expect(computeWorkspaceRootAsync).toHaveBeenCalled()) + expect(addWorktreeMock).not.toHaveBeenCalled() + root.resolve('/workspace/worktrees') + await create + expect(computeWorktreePathMock).toHaveBeenCalledWith( + 'feature', + '/workspace/repo', + { nestWorkspaces: false, workspaceDir: '../worktrees' }, + '/workspace/worktrees' + ) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', '../worktrees/feature', @@ -689,6 +701,10 @@ describe('registerWorktreeHandlers', () => { expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh') expect(result.setup).toBeUndefined() expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' }) + expect(runtimeStub.invalidateWorktreeCatalog).toHaveBeenCalledWith('repo-1') + expect(runtimeStub.invalidateWorktreeCatalog.mock.invocationCallOrder[0]).toBeLessThan( + runtimeStub.createTerminal.mock.invocationCallOrder[0] + ) expect(result.timing?.phases.map((phase) => phase.phase)).toEqual( expect.arrayContaining([ 'git_worktree_add', diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index a1925d1ce72..8d2787fcf2f 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -1,4 +1,5 @@ import { type Mock, vi } from 'vitest' +import type { computeWorktreePath } from './worktree-logic' import type { HandlerMap } from './worktrees-test-ipc-surface' /** Loose signature: one mock stands in for many unrelated module exports. */ @@ -78,13 +79,7 @@ export const resolveSetupRunnerShellMock: ModuleMock = vi.fn() export const runHookMock: ModuleMock = vi.fn() export const hasHooksFileMock: ModuleMock = vi.fn() export const loadHooksMock: ModuleMock = vi.fn() -export const computeWorktreePathMock: Mock< - ( - sanitizedName: string, - repoPath: string, - settings: { nestWorkspaces: boolean; workspaceDir: string } - ) => string -> = vi.fn() +export const computeWorktreePathMock: Mock = vi.fn() export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn() export const gitExecFileAsyncMock: GitArgvMock = vi.fn() export const getSshGitProviderMock: StringArgMock = vi.fn() @@ -138,7 +133,19 @@ export const gitRepoModuleMock = () => ({ resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getDefaultRemote: getDefaultRemoteMock, - getBranchConflictKind: getBranchConflictKindMock + getBranchConflictKind: async ( + repoPath: string, + branch: string, + base?: string, + options?: { wslDistro?: string }, + allowLocalBranch?: () => Promise + ) => { + // These handler tests stub ref presence; policy tests cover the absent-ref fast path. + if (allowLocalBranch && (await allowLocalBranch())) { + return null + } + return getBranchConflictKindMock(repoPath, branch, base, options) + } }) export const githubClientModuleMock = () => ({ diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index 647d4801d13..bb2f33cb05e 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -12,6 +12,7 @@ export type WorktreeRuntimeStub = { clearOptimisticReconcileToken: ReturnType resolveManagedMrBase: ReturnType createTerminal: ReturnType + invalidateWorktreeCatalog: ReturnType splitTerminal: ReturnType notifyWorktreesChangedForRemoteClients: ReturnType closeFileWatchersForRemoval: ReturnType @@ -38,6 +39,7 @@ export function createWorktreeRuntimeStub(): WorktreeRuntimeStub { title: null, surface: 'visible' }), + invalidateWorktreeCatalog: vi.fn(), splitTerminal: vi.fn().mockResolvedValue({ handle: 'term-setup', tabId: 'tab-startup', diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts index 2f1ab7abad9..c1bcbbf3d59 100644 --- a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -15,15 +15,13 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo return } try { - const baseBranch = await prefetchWorktreeCreateBase({ + await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime, - gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo) + gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo), + prepareCheckout: (base) => prepareWorktreeCreateForRepo(store, repo, base) }) - if (baseBranch) { - await prepareWorktreeCreateForRepo(store, repo, baseBranch) - } } catch { // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. } diff --git a/src/main/jira/attachment-image-cache-generation.test.ts b/src/main/jira/attachment-image-cache-generation.test.ts new file mode 100644 index 00000000000..e92ca5c5a53 --- /dev/null +++ b/src/main/jira/attachment-image-cache-generation.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + _resetAttachmentImageCache, + clearAttachmentImagesForSite, + getCachedAttachmentDataUrl, + loadAttachmentDataUrlWithCache +} from './attachment-image-cache' + +type Image = { dataUrl: string; byteSize: number } | null +function deferredImage() { + let resolve!: (image: Image) => void + let reject!: (error: Error) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +beforeEach(_resetAttachmentImageCache) + +describe.each(['site', 'all'] as const)('attachment download after clearing %s', (scope) => { + it.each(['success', 'empty', 'failure'] as const)( + 'keeps the replacement singleflight when the old download completes with %s', + async (outcome) => { + const old = deferredImage() + const replacement = deferredImage() + let downloads = 0 + const load = () => { + downloads += 1 + return downloads === 1 ? old.promise : replacement.promise + } + const args = { siteId: 'site-a', attachmentId: 'image-1', load } + const first = loadAttachmentDataUrlWithCache(args).catch(() => 'old failure') + clearAttachmentImagesForSite(scope === 'site' ? 'site-a' : undefined) + const second = loadAttachmentDataUrlWithCache(args) + expect(downloads).toBe(2) + + if (outcome === 'failure') { + old.reject(new Error('old failure')) + } else { + old.resolve(outcome === 'empty' ? null : { dataUrl: 'old image', byteSize: 3 }) + } + expect(await first).toBe( + outcome === 'failure' ? 'old failure' : outcome === 'empty' ? null : 'old image' + ) + expect(getCachedAttachmentDataUrl('site-a', 'image-1')).toBeNull() + + const third = loadAttachmentDataUrlWithCache(args) + expect(downloads).toBe(2) + replacement.resolve({ dataUrl: 'new image', byteSize: 3 }) + expect(await second).toBe('new image') + expect(await third).toBe('new image') + expect(getCachedAttachmentDataUrl('site-a', 'image-1')).toBe('new image') + } + ) +}) diff --git a/src/main/jira/attachment-image-cache.ts b/src/main/jira/attachment-image-cache.ts index f9fdbfe3f7d..9d118ffdc9c 100644 --- a/src/main/jira/attachment-image-cache.ts +++ b/src/main/jira/attachment-image-cache.ts @@ -133,7 +133,10 @@ export async function loadAttachmentDataUrlWithCache(args: { } return loaded.dataUrl } finally { - inFlight.delete(key) + // A cleared generation no longer owns the current download's singleflight slot. + if (currentEpoch(args.siteId) === epochAtStart) { + inFlight.delete(key) + } } })() diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts new file mode 100644 index 00000000000..d6424ce7952 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -0,0 +1,196 @@ +// An empty chat beside a pre-SQLite journal explains itself. +// +// The SQLite move shipped no importer, so a session whose history is a +// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a +// chat created seconds ago. One status row is the difference. + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' +import { openJournalDatabase } from './journal-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' +import type { AgentSessionJournal } from './journal-store' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +function writeRemnant(name = 'log.jsonl'): Promise { + return writeFile(join(root, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') +} + +function disclosure(journal: AgentSessionJournal): string | null { + const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID) + return row?.body.kind === 'status' ? row.body.text : null +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat whose history is still in the pre-SQLite format', () => { + it('says how to carry on, and where the transcript is', async () => { + await writeRemnant() + + const journal = await open() + + expect(disclosure(journal)).toContain('send a message to pick up where you left off') + expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).toContain('Codex') + }) + + // Both files is the normal shape of a pre-SQLite directory: every epoch roll + // staged a snapshot whether or not anything compacted into it, so preferring + // the snapshot would name an empty file for ~every affected chat. + it('names the log, not the snapshot staged beside it', async () => { + await writeRemnant('log.jsonl') + await writeRemnant('snapshot.json') + + const journal = await open() + + expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).not.toContain('snapshot.json') + }) + + it('falls back to the snapshot when a chat has no log beside it', async () => { + await writeRemnant('snapshot.json') + + const journal = await open() + + expect(disclosure(journal)).toContain(join(root, 'snapshot.json')) + }) + + it('says nothing to a chat that is genuinely new', async () => { + const journal = await open() + + expect(journal.snapshot().items).toEqual([]) + }) + + // Counting rows proves nothing here — the append upserts by identity, so a + // second append would still leave exactly one. The revision is what moves. + it('does not re-append the row on a later open', async () => { + await writeRemnant() + const first = await open() + const firstRevision = first + .snapshot() + .items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision + await first.close() + + const reopened = await open() + + const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID) + expect(firstRevision).toBe(1) + expect(row?.revision).toBe(1) + expect(reopened.cursor().sequence).toBe(2) + }) + + // The epoch commit and this append are separate transactions; if the append is + // lost the epoch exists but holds nothing, and every later open takes the + // adopt branch. The offer has to survive that. + it('offers the message again when a committed epoch holds nothing', async () => { + const founded = await open() + await founded.close() + await writeRemnant() + + const reopened = await open() + + expect(disclosure(reopened)).toContain(join(root, 'log.jsonl')) + }) + + // A repair's epoch is the marker that history was deleted and never rebuilt, + // and any row that is not the repair's own disclosure retires it. Appending + // here would silently stop the session ever asking the provider for that + // history — with the journal still holding none. + it('stays out of a journal this open just repaired', async () => { + const journal = await open() + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, + { fence: 1 } + ) + await journal.close() + // Deleting the anchor leaves every row unanchored: replay keeps nothing, so + // the repair publishes an empty `unreconcilable_prefix` epoch and — costing + // no malformed row — appends no disclosure of its own. That is the one state + // where this branch and a repair meet. + const opened = openJournalDatabase(journalDatabaseFile(root)) + try { + opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + } finally { + opened.db.close() + } + await writeRemnant() + + const repaired = await open() + + expect(disclosure(repaired)).toBeNull() + // Still asking the provider for the history the repair dropped. + expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + }) + + // A latched journal loads empty, so it reaches the same branch — and an append + // into one throws, which would make the session unopenable rather than read-only. + it('writes nothing into a journal latched by a newer schema', async () => { + const founded = await open() + await founded.close() + const db = new Database(journalDatabaseFile(root)) + try { + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + } finally { + db.close() + } + await writeRemnant() + + const latched = await open() + + expect(latched.isReadOnly).toBe(true) + expect(disclosure(latched)).toBeNull() + }) + + // A row nothing projects is a row nobody reads. + it('renders in the transcript as a system line', async () => { + await writeRemnant() + + const journal = await open() + + const messages = projectStructuredItemsToNativeChat(journal.snapshot().items) + expect(messages).toHaveLength(1) + expect(messages[0]?.role).toBe('system') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts new file mode 100644 index 00000000000..69dcc04bf64 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts @@ -0,0 +1,60 @@ +// A journal directory left behind by the pre-SQLite file format. +// +// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. +// This is Orca's own `log.jsonl`, which no build after the SQLite move reads. +// Nothing imports it, so the session it belonged to opens empty and is +// indistinguishable from a chat created seconds ago — same `session_created` +// epoch, same empty timeline. The remnant is the one durable fact that tells +// them apart, so the empty session says where its history went and how to carry +// on instead of silently claiming it never had any. + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { boundJournalStatusText } from './journal-prompt-body-bounds' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import type { AgentType } from '../../../shared/agent-status-types' + +/** The remnant's transcript, or null when the directory never held one. + * + * `log.jsonl` first, and the order matters: every epoch roll staged a + * `snapshot.json` whether or not anything was ever compacted into it, so the + * file's existence says nothing about where the history lives. Measured across + * a real profile, `compactedThrough` was 0 in all 80 — the log holds the + * transcript and the snapshot is the fallback for a session that has no log. */ +export function findJournalFileFormatRemnant(journalDir: string): string | null { + for (const name of ['log.jsonl', 'snapshot.json']) { + const path = join(journalDir, name) + if (existsSync(path)) { + return path + } + } + return null +} + +/** One stable identity, so a reopen upserts the same row instead of adding one. */ +export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'journal-file-format-remnant' +} + +/** How to carry on. The session attaches on the record's own provider handle, so it + * still names the conversation the transcript no longer shows — whether the provider + * itself still holds that thread is its own business, hence "points at". */ +export function journalFileFormatRemnantDisclosure(input: { + transcriptPath: string + agent: AgentType +}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } { + return { + identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY, + body: { + kind: 'status', + text: boundJournalStatusText( + `This chat's history was saved in an older format Orca no longer reads, so it starts ` + + `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + + `conversation — send a message to pick up where you left off. The original ` + + `transcript is on the session's host at \`${input.transcriptPath}\`` + ) + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index e002048a094..721e5f4ba7f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -1,7 +1,16 @@ import { mkdir } from 'node:fs/promises' +import type { AgentType } from '../../../shared/agent-status-types' +import { + findJournalFileFormatRemnant, + journalFileFormatRemnantDisclosure +} from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure' +/** What any of this file's disclosures hands the store — a repair's, or the + * pre-SQLite notice's. Same shape, and neither is only a repair. */ +type JournalDisclosure = JournalRepairDisclosure + export async function ensureJournalDir(journalDir: string): Promise { await mkdir(journalDir, { recursive: true }) } @@ -32,6 +41,7 @@ export async function openJournalStoreState(input: { body: JournalRepairDisclosure['body'], fence: number ) => Promise + agent: AgentType highestFence: () => number malformedRows: () => number setMalformedRows: (count: number) => void @@ -40,6 +50,7 @@ export async function openJournalStoreState(input: { const loaded = input.loaded !== undefined ? input.loaded : input.replay() if (!loaded) { input.start() + await discloseFileFormatRemnant(input) return } input.adopt(loaded) @@ -59,4 +70,43 @@ export async function openJournalStoreState(input: { const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() }) await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence()) } + // Founding the epoch and appending the row are two transactions, and a + // committed epoch sends every later open down this branch instead. Anything + // that interrupts between them — a quit during startup restore, a failed + // append — would otherwise lose the message for good. An epoch holding nothing + // is exactly the state that append was owed, so offer it again. + // + // Never onto a repair, though: `loaded.state` is the PRE-repair load, so a + // journal this open just emptied looks identical. The repair's epoch is the + // marker that its history was deleted and never rebuilt, and any row that is + // not the repair's own disclosure retires it — this row would silently stop + // the session ever asking the provider for that history again. + if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { + await discloseFileFormatRemnant(input) + } +} + +/** Says what happened to a chat whose history is in the abandoned file format. + * Upserts by a constant identity, so the offer above is exactly-once in effect: + * once the row exists the epoch is no longer empty. */ +async function discloseFileFormatRemnant(input: { + journalDir: string + agent: AgentType + appendDisclosure: ( + identity: JournalDisclosure['identity'], + body: JournalDisclosure['body'], + fence: number + ) => Promise + highestFence: () => number + readOnly: () => boolean +}): Promise { + if (input.readOnly()) { + return + } + const transcriptPath = findJournalFileFormatRemnant(input.journalDir) + if (!transcriptPath) { + return + } + const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent }) + await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence()) } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 53683a797d1..3a5d3c7ac6e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -41,6 +41,7 @@ export function restoreJournalStore( adopt: host.adopt, appendDisclosure: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), + agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, setMalformedRows: host.setMalformedRows, diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 22bd645d8a6..9860aaa81d8 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -112,4 +112,52 @@ describe('provider frame classification catalog', () => { // An item type nobody has dispositioned still falls through visibly. expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive') }) + + it('chromes the one unmodelled codex item type that carries no content', () => { + expect(classifyProviderFrame('codex', 'item:sleep', { id: 's', durationMs: 20_000 })).toBe( + 'status-chrome' + ) + // Payload inspection still outranks the item catalog, so chroming a type + // cannot swallow one that reports a failure. + expect(classifyProviderFrame('codex', 'item:sleep', { id: 's', status: 'failed' })).toBe( + 'error-surface' + ) + }) + + it('keeps subagent items visible — the only evidence a spawned agent is working', () => { + expect( + classifyProviderFrame('codex', 'item:subAgentActivity', { + id: 'a-1', + kind: 'started', + agentThreadId: 'thread-child', + agentPath: '/root/list_directory' + }) + ).toBe('timeline-substantive') + expect( + classifyProviderFrame('codex', 'item:collabAgentToolCall', { + id: 'c-1', + tool: 'spawn', + status: 'inProgress', + senderThreadId: 'thread-root', + receiverThreadIds: ['thread-child'], + agentsStates: {} + }) + ).toBe('timeline-substantive') + }) + + it('leaves content-bearing codex item types on the visible fallback', () => { + // Each carries text or a path a user would want: review output, the image + // the agent looked at or generated, injected hook prompt text. + for (const type of [ + 'imageView', + 'imageGeneration', + 'enteredReviewMode', + 'exitedReviewMode', + 'hookPrompt' + ]) { + expect(classifyProviderFrame('codex', `item:${type}`, { id: 'i' }), type).toBe( + 'timeline-substantive' + ) + } + }) }) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 474b1385a4f..f05f4cd4c6c 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -197,7 +197,12 @@ function hasProviderError(payload: unknown): boolean { const CODEX_ITEM_CLASSIFICATIONS: Record = { // The `thread/compacted` notification is already chrome; its item form is the // same event and must not read as a mysterious opcode row. - contextCompaction: 'status-chrome' + contextCompaction: 'status-chrome', + // `{id, durationMs}` and nothing else — Codex's own transcript renders it as + // nothing at all. Every other item type this build does not model carries text + // a user would want (review output, an image path, hook prompt text, subagent + // progress), so those keep their visible fallback row. + sleep: 'status-chrome' } function notificationKind(kind: string): string { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e44e8c39152..e6f8e478695 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -137,7 +137,11 @@ export type StructuredAgentSessionAdapter = { turnId: string fence: number }): Promise<{ cancelled: boolean }> - stopBackgroundTasks?(input: { sessionId: string; fence: number }): Promise<{ cancelled: boolean }> + stopBackgroundTasks?(input: { + sessionId: string + fence: number + taskId?: string + }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined /** Fires the provider callback for an approval or a question. The wire calls * this only after the durable compare-and-set won, so it runs exactly once. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts index 70fc9a43ed2..b8e198c9b6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -8,7 +8,7 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' -function providerSessionMetadata( +export function structuredAgentSessionProviderSessionMetadata( record: AgentSessionRecord | null ): AgentProviderSessionMetadata | undefined { const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null @@ -27,7 +27,7 @@ export function readStructuredAgentSessionHistoryResult(input: { }): AgentSessionHistoryResult { const result = readAgentSessionHistory(input.journal, input.request) const fence = input.record?.lease.runtimeFence - const providerSession = providerSessionMetadata(input.record) + const providerSession = structuredAgentSessionProviderSessionMetadata(input.record) if (fence === undefined) { return providerSession ? { ...result, providerSession } : result } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 2afd94ba128..ba62db1704e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -19,6 +19,8 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' +import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -67,6 +69,27 @@ export async function evictHeldStructuredAgentSession( ) } +/** The first hold on a childless session: reconcile the lease, settle recovery, then attach. */ +export async function resumeStructuredAgentSessionForHold( + context: StructuredAgentSessionLifetimeContext & { + reconcileLeases: (sessionId: string) => Promise + }, + sessionId: string, + attach: Parameters[0]['attach'] +): Promise { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + throw new Error(unreconciled.code) + } + await context.runtimeState.resolveRecovery(sessionId) + await resumeHeldStructuredAgentSession({ + sessionId, + deps: context.deps, + now: context.now, + attach + }) +} + export function createStructuredAgentSessionHolds( context: StructuredAgentSessionLifetimeContext, input: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 7d2648930c4..f4a0244d0af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -78,6 +78,7 @@ export function cancelStructuredAgentSessionTurn( envelope: AgentSessionMutationEnvelope turnId: string scope?: 'background-tasks' + taskId?: string } ): Promise> { return mutate(context, caller, params.envelope, cancelPlan(params)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index bda921d9d7f..ea4b594ac44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -10,6 +10,17 @@ import type { StructuredAgentSessionHandoffTransport } from './structured-agent- export type StructuredAgentSessionCaller = { callerKey: string } +/** What the host believes about a session it just made addressable again. The workspace and agent + * come from the record, so a caller publishes the host's view rather than a client's assertion. + * `readable` is false when the journal could not be opened — the tab is still worth publishing, + * because attach recovers what read restore cannot. */ +export type StructuredAgentSessionReveal = { + sessionId: string + workspaceId: string + agent: 'claude' | 'codex' + readable: boolean +} + export type StructuredAgentSessionHostSession = { journal: AgentSessionJournal params: AgentSessionAttachParams diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8989f5e4d72..13b7d7b441e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -2,17 +2,7 @@ // Mutations share one durable admission path and serialize per session. import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { - AgentSessionAttachResult, - AgentSessionHistoryRequest, - AgentSessionHistoryResult, - AgentSessionHandoffRequest, - AgentSessionHandoffResult, - AgentSessionHandoffStatus, - AgentSessionMutationResult, - AgentSessionOptionsResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' +import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' @@ -22,7 +12,7 @@ import { } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal' import { createStructuredAgentSessionHostHandoff, refreshRecoverableStructuredHandoffStatus, @@ -33,13 +23,13 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { createStructuredAgentSessionHolds, evictHeldStructuredAgentSession, + resumeStructuredAgentSessionForHold, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHolds, StructuredAgentSessionHoldOptions } from './structured-agent-session-holds' -import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' import { @@ -50,13 +40,14 @@ import { setStructuredAgentSessionOption, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionReveal } from './structured-agent-session-host-types' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { withTimeout } from '../../../shared/promise-timeout-fallback' @@ -66,13 +57,21 @@ const HANDOFF_DRAIN_TIMEOUT_MS = 5_000 export class StructuredAgentSessionHost { private readonly sessions = new Map() - private readonly subscribers = new AgentSessionSubscribers() + private readonly statusFeed = new StructuredAgentSessionStatusFeed({ + sessions: this.sessions, + getRecord: (sessionId) => this.deps.store.getRecord(sessionId), + now: () => this.now() + }) + private readonly subscribers = new AgentSessionSubscribers({ + onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal) + }) private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState - private readonly reconcileLeases: (sessionId: string) => Promise + private readonly reconcileLeases: ( + sessionId: string + ) => Promise private readonly handoffs: StructuredAgentSessionHostHandoff - private readonly readableRestorer: StructuredAgentSessionReadableRestorer - private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate() + private readonly restore: ReturnType private readonly holds: StructuredAgentSessionHolds private readonly eventRecovery: StructuredAgentSessionEventRecovery private readonly backgroundTasks: StructuredAgentSessionBackgroundTaskChannel @@ -112,20 +111,25 @@ export class StructuredAgentSessionHost { now: this.now }) this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { - resume: (sessionId) => this.resumeForHold(sessionId), + resume: (sessionId) => + resumeStructuredAgentSessionForHold( + { ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases }, + sessionId, + (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) + ), evict: (sessionId) => this.close(sessionId) }) - this.readableRestorer = new StructuredAgentSessionReadableRestorer({ - store: deps.store, - journalRoot: deps.journalRoot, - supportsRecord: (record) => providerSupport.adapterSupportsRecord(deps.adapter, record), + this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - hasSession: (sessionId) => this.sessions.has(sessionId), + hasSession: this.hasSession, // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. - onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored), + onReadable: (sessionId, restored) => { + this.sessions.set(sessionId, restored) + this.statusFeed.publish(sessionId) + }, restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ @@ -160,20 +164,6 @@ export class StructuredAgentSessionHost { /** That surface is gone. The child outlives it by the release grace, and by any running turn. */ release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) - private async resumeForHold(sessionId: string): Promise { - const unreconciled = await this.reconcileLeases(sessionId) - if (unreconciled) { - throw new Error(unreconciled.code) - } - await this.runtimeState.resolveRecovery(sessionId) - await resumeHeldStructuredAgentSession({ - sessionId, - deps: this.deps, - now: () => this.now(), - attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) - }) - } - handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) @@ -234,7 +224,11 @@ export class StructuredAgentSessionHost { } restoreReadableSessions = (sessionIds?: readonly string[]): Promise => - this.restartRestore.run(() => this.readableRestorer.restore(sessionIds)) + this.restore.restoreReadableSessions(sessionIds) + + /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ + revealSession = (sessionId: string): Promise => + this.restore.revealSession(sessionId) private serialize = (sessionId: string, task: () => Promise): Promise => this.tasks.serialize(sessionId, task) @@ -250,7 +244,7 @@ export class StructuredAgentSessionHost { attach( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams - ): Promise> { + ): Promise> { return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) } @@ -316,22 +310,23 @@ export class StructuredAgentSessionHost { requestHandoff = ( caller: StructuredAgentSessionCaller, - params: AgentSessionHandoffRequest - ): Promise> => + params: SessionWire.AgentSessionHandoffRequest + ): Promise> => this.handoffs.request(caller.callerKey, params) - readOptions = (sessionId: string): Promise => + readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) - async handoffStatus(sessionId: string): Promise { + async handoffStatus(sessionId: string): Promise { this.requireSession(sessionId) return this.serialize(sessionId, () => refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) ) } - history = (request: AgentSessionHistoryRequest): AgentSessionHistoryResult => - this.backgroundTasks.history(request) + history = ( + request: SessionWire.AgentSessionHistoryRequest + ): SessionWire.AgentSessionHistoryResult => this.backgroundTasks.history(request) subscribe = (input: AgentSessionSubscribeInput): (() => void) => this.backgroundTasks.subscribe(input) @@ -342,6 +337,11 @@ export class StructuredAgentSessionHost { ) => this.backgroundTasks.publish(sessionId, state) unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) + /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ + subscribeStatus = ( + subscriber: Parameters[0] + ): (() => void) => this.statusFeed.subscribe(subscriber) + private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) if (!session) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index d0eb905443c..1194f0c87ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -76,15 +76,21 @@ export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope turnId: string scope?: 'background-tasks' + taskId?: string }): MutationPlan { return { method: 'agentSession.cancel', - fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}) }, + fields: { + turnId: params.turnId, + ...(params.scope ? { scope: params.scope } : {}), + ...(params.taskId ? { taskId: params.taskId } : {}) + }, run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, turnId: params.turnId, - ...(params.scope ? { scope: params.scope } : {}) + ...(params.scope ? { scope: params.scope } : {}), + ...(params.taskId ? { taskId: params.taskId } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts new file mode 100644 index 00000000000..34e3fb8a4cf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -0,0 +1,110 @@ +// Read restore decides whether a session comes back at all. +// +// A chat still in the pre-SQLite format has no `journal.db`, so the probe that +// loads one reports nothing. Reading that as "no session" is what removed these +// chats: an unpublished session is also what prunes its tab out of the saved +// workspace, so the tab is gone before anything can explain itself. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' + +const SESSION_ID = 'codex_read_restore_fixture' +const WORKSPACE_ID = 'repo-1::/tmp/workspace' + +const RECORD = { + schemaVersion: 2, + sessionId: SESSION_ID, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [ + { + linkId: 'codex-1-thread-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + createdAt: 1, + updatedAt: 2, + lease: { sessionId: SESSION_ID, runtimeKind: 'native', runtimeFence: 1 } +} as unknown as AgentSessionRecord + +const store = { + getRecord: (sessionId: string) => (sessionId === SESSION_ID ? RECORD : null) +} as unknown as AgentSessionRecordStore + +let journalRoot: string +const opened: AgentSessionJournal[] = [] + +async function writeRemnant(name: string): Promise { + const dir = journalDirectoryFor(journalRoot, { + workspaceId: WORKSPACE_ID, + sessionId: SESSION_ID + }) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') + return join(dir, name) +} + +beforeEach(async () => { + journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) +}) + +afterEach(async () => { + await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + await rm(journalRoot, { recursive: true, force: true }) +}) + +describe('a session whose journal is still the pre-SQLite format', () => { + it('is published, carrying the message that explains it', async () => { + const transcript = await writeRemnant('log.jsonl') + + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + + expect(restored).not.toBeNull() + opened.push(restored!.journal) + const disclosed = restored!.journal + .snapshot() + .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) + expect(disclosed.join('')).toContain(transcript) + // Publishing it costs no agent process; acquisition still waits for the user. + expect(restored!.hasProviderChild).toBe(false) + }) + + it('is published for a remnant whose log is gone', async () => { + await writeRemnant('snapshot.json') + + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + + expect(restored).not.toBeNull() + opened.push(restored!.journal) + }) + + it('still drops a session with neither a journal nor a remnant', async () => { + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID) + + expect(restored).toBeNull() + }) + + it('still drops a session with no record', async () => { + await writeRemnant('log.jsonl') + + const restored = await restoreStructuredAgentSessionRead(store, journalRoot, 'unknown-session') + + expect(restored).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 5bed8f2920e..34fd6452b08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' import { loadJournal } from '../agent-session-journal/journal-open' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -40,15 +41,27 @@ export async function restoreStructuredAgentSessionRead( sessionId }) const loaded = loadJournal(journalDir, sessionId) - if (!loaded || loaded.corrupt) { + if (loaded?.corrupt) { + return null + } + // A session still in the pre-SQLite format has no `journal.db` to load. Dropping + // it here leaves it unpublished, which is also what prunes its tab out of the + // saved workspace — so the chat disappears with nowhere to explain itself. + if (!loaded && !findJournalFileFormatRemnant(journalDir)) { return null } const journal = await openAgentSessionJournal({ identity: journalIdentityFor(record, params), journalDir, - loaded + // Omitted, not `null`: the store reads `null` as "replay already ran and + // found nothing" and founds a fresh epoch. In process the probe above is the + // previous statement, so the window is zero-width; this holds the line for a + // database another process creates in between. + ...(loaded ? { loaded } : {}) }) - // Read restore opens the journal and nothing else: no adapter call, so no provider child. + // Read restore opens the journal and nothing else: no adapter call, so no + // provider child. Opening it can still write — a session whose history is in + // the old format founds its epoch and commits the row explaining that here. return { journal, params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index f4e09509b07..e3b96dbd36a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -2,7 +2,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { RestoredStructuredAgentSessionRead } from './structured-agent-session-read-restore' -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +import { + restoreOneStructuredAgentSessionRead, + restoreStructuredAgentSessionsOnRestart +} from './structured-agent-session-restart-restore' export class StructuredAgentSessionReadableRestorer { private restorePromise: Promise | null = null @@ -29,6 +32,26 @@ export class StructuredAgentSessionReadableRestorer { return this.restorePromise } + /** + * One session, on demand, after startup. + * + * Deliberately outside `restorePromise`: that latch answers "has the startup sweep run", and a + * surface asking for a session the sweep never covered — or that was closed since — must not be + * told yes because the sweep finished. Needs no dedupe of its own; the per-session task queue + * `serialize` runs on already orders concurrent callers, and the second one sees `hasSession`. + * + * Provider-agnostic by construction: eligibility is `supportsRecord`, which the adapter router + * answers for Claude and Codex from the record's own provider. + */ + async restoreOne(sessionId: string): Promise { + const record = this.input.store.getRecord(sessionId) + if (!record || !this.input.supportsRecord(record)) { + return false + } + await restoreOneStructuredAgentSessionRead(this.input, sessionId) + return this.input.hasSession(sessionId) + } + private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { const targetOrder = sessionIds ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 6eb6fe15059..174aac7d72b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -22,39 +22,56 @@ import { const JOURNAL_RESTORE_CONCURRENCY = 4 -export async function restoreStructuredAgentSessionsOnRestart(input: { +export type StructuredAgentSessionReadRestoreDeps = { store: AgentSessionRecordStore journalRoot: string - records: AgentSessionRecord[] reconcile: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void restoreHandoff: (sessionId: string) => Promise -}): Promise { - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { - const unreconciled = await input.reconcile(sessionId) - if (!unreconciled) { - // A session latched in recovery exits here at startup, without waiting for a client. - await input.resolveRecovery(sessionId) - } - await input.serialize(sessionId, async () => { - if (input.hasSession(sessionId)) { - // A surface that took a hold mid-restore already attached this one. - await input.restoreHandoff(sessionId) - return - } - const restored = await restoreStructuredAgentSessionRead( - input.store, - input.journalRoot, - sessionId - ) - if (!restored) { - return - } - input.onReadable(sessionId, restored) +} + +/** + * One session's share of the restart restore, and the whole of an on-demand one. + * + * Startup maps this over every supported record; a surface asking for a session it cannot see + * calls it for one id. The CALLER decides which records are eligible — startup filters by + * `supportsRecord` before mapping, so an on-demand caller owes the same check. + */ +export async function restoreOneStructuredAgentSessionRead( + input: StructuredAgentSessionReadRestoreDeps, + sessionId: string +): Promise { + const unreconciled = await input.reconcile(sessionId) + if (!unreconciled) { + // A session latched in recovery exits here at startup, without waiting for a client. + await input.resolveRecovery(sessionId) + } + await input.serialize(sessionId, async () => { + if (input.hasSession(sessionId)) { + // A surface that took a hold mid-restore already attached this one. await input.restoreHandoff(sessionId) - }) + return + } + const restored = await restoreStructuredAgentSessionRead( + input.store, + input.journalRoot, + sessionId + ) + if (!restored) { + return + } + input.onReadable(sessionId, restored) + await input.restoreHandoff(sessionId) }) } + +export async function restoreStructuredAgentSessionsOnRestart( + input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } +): Promise { + await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, ({ sessionId }) => + restoreOneStructuredAgentSessionRead(input, sessionId) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts new file mode 100644 index 00000000000..884fba78a4e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -0,0 +1,149 @@ +// Startup restore has to publish status, not just index the session. +// +// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a +// session read-only, without a provider child, so the only thing that can surface its state is +// the status publication the restore wiring makes. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + AgentSessionMutationEnvelope, + AgentSessionStatusEvent +} from '../../../shared/agent-session-wire' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +const hosts: StructuredAgentSessionHost[] = [] +let root = '' + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } +} + +function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner: async () => ({ + outcome: 'indeterminate', + reason: 'read does not need ownership' + }), + now: () => NOW + }) + hosts.push(host) + return host +} + +function sendEnvelope( + store: AgentSessionRecordStore, + fields: Record +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + } +} + +/** Persists one turn, then hands back a restarted host over the same directories. */ +async function restartWithPersistedTurn(): Promise { + root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) + resetHostTestOperationIds() + const directory = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const host = createHost(store) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const body = hostTestMessage('persisted conversation') + await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + await host.flushAllStreamedEvents() + return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) +} + +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) + await rm(root, { recursive: true, force: true }) + root = '' +}) + +describe('structured session restart status publication', () => { + // Served by the subscribe-time re-projection rather than the restore's own publish, so this + // covers what a restored journal projects — not the restore wiring. The test below pins that. + it('projects the persisted turn of a session restored without a provider', async () => { + const restarted = await restartWithPersistedTurn() + + await restarted.restoreReadableSessions() + const events: AgentSessionStatusEvent[] = [] + restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) + + expect(events).toEqual([ + { + type: 'snapshot', + sessions: [ + expect.objectContaining({ + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'idle', + latestPrompt: 'persisted conversation' + }) + ] + } + ]) + }) + + it('publishes a restored session to a list already sitting on the stream', async () => { + const restarted = await restartWithPersistedTurn() + const events: AgentSessionStatusEvent[] = [] + restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) + expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) + + await restarted.restoreReadableSessions() + + // The restore wiring publishes; without it this list never hears about the session at all. + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts new file mode 100644 index 00000000000..e2ef20503d6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -0,0 +1,236 @@ +/** + * Revealing a persisted chat, for both structured providers. + * + * The reveal path is the only way an Agent Session History row reaches a chat whose tab this + * process never published — a chat closed cleanly, or one this process has not opened since + * launch. It is exercised here through the readable restorer rather than the whole host, because + * what it must get right is which records it accepts and what it does when the journal is gone. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import * as readRestore from './structured-agent-session-read-restore' +import * as providerSupport from './structured-agent-session-provider-support' +import { revealStructuredAgentSession } from './structured-agent-session-reveal' + +function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessionRecord { + const record = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { + ...record, + provider, + providerHandleChain: + provider === 'codex' + ? [ + { + ...record.providerHandleChain[0]!, + handle: { provider: 'codex', threadId: `thread-${sessionId}` } + } + ] + : record.providerHandleChain + } +} + +function harness( + records: AgentSessionRecord[], + options: { supports?: (record: AgentSessionRecord) => boolean } = {} +) { + const live = new Map() + const restoreHandoff = vi.fn(async () => undefined) + const serializedIds: string[] = [] + const serialize = (sessionId: string, task: () => Promise): Promise => { + serializedIds.push(sessionId) + return task() + } + const restorer = new StructuredAgentSessionReadableRestorer({ + store: { + getRecord: (sessionId: string) => records.find((r) => r.sessionId === sessionId) ?? null, + listRecords: () => records + } as never, + journalRoot: '/journals', + supportsRecord: options.supports ?? (() => true), + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize, + hasSession: (sessionId) => live.has(sessionId), + onReadable: (sessionId, restored) => live.set(sessionId, restored), + restoreHandoff + }) + return { restorer, live, restoreHandoff, serializedIds } +} + +const readable = { journal: {}, params: {}, fence: 1 } as never + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('revealing one structured session on demand', () => { + beforeEach(() => { + vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(readable) + }) + + it.each(['claude', 'codex'] as const)('restores a persisted %s chat', async (provider) => { + const sessionId = `session-${provider}` + const { restorer, live } = harness([recordFor(provider, sessionId)]) + + await expect(restorer.restoreOne(sessionId)).resolves.toBe(true) + expect(live.has(sessionId)).toBe(true) + }) + + it('does not need the startup sweep to have run, or run it', async () => { + // The whole point: `restore()` is latched to once per process, and a surface asking later must + // not be answered from that latch — nor trip it, which would skip every other record. + const records = [recordFor('codex', 'session-asked'), recordFor('claude', 'session-untouched')] + const { restorer, live } = harness(records) + + await restorer.restoreOne('session-asked') + + expect(live.has('session-asked')).toBe(true) + expect(live.has('session-untouched')).toBe(false) + }) + + it('serializes against the session it restores', async () => { + const { restorer, serializedIds } = harness([recordFor('claude', 'session-serialized')]) + + await restorer.restoreOne('session-serialized') + + // Ordering against close/eviction is the task queue's job, so the restore must be inside it. + expect(serializedIds).toEqual(['session-serialized']) + }) + + it('returns the live session instead of restoring over it', async () => { + const { restorer, live } = harness([recordFor('codex', 'session-live')]) + live.set('session-live', readable) + + await expect(restorer.restoreOne('session-live')).resolves.toBe(true) + expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() + }) + + it('refuses a record no adapter supports', async () => { + const { restorer } = harness([recordFor('codex', 'session-unsupported')], { + supports: () => false + }) + + await expect(restorer.restoreOne('session-unsupported')).resolves.toBe(false) + expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() + }) + + it('refuses a session this host holds no record for', async () => { + const { restorer } = harness([]) + + await expect(restorer.restoreOne('session-absent')).resolves.toBe(false) + }) +}) + +describe('a record whose journal cannot be read', () => { + it('reports not-readable without throwing, for either provider', async () => { + // A chat whose journal predates the SQLite store restores to nothing here. That is not a + // refusal: attach still recovers it, so the caller publishes the tab and lets the pane's hold + // finish the job. Throwing, or reporting success, would both be wrong. + vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(null) + const { restorer, live } = harness([ + recordFor('claude', 'session-no-journal-claude'), + recordFor('codex', 'session-no-journal-codex') + ]) + + await expect(restorer.restoreOne('session-no-journal-claude')).resolves.toBe(false) + await expect(restorer.restoreOne('session-no-journal-codex')).resolves.toBe(false) + expect(live.size).toBe(0) + }) +}) + +describe('the host answer a client acts on', () => { + beforeEach(() => { + // Eligibility is the router's call; these cases are about what the answer carries. + vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(true) + }) + + function record(provider: 'claude' | 'codex', workspaceId: string) { + const base = recordFor(provider, 'session-answered') + return { ...base, location: { ...base.location, workspaceId } } + } + + it("answers with the record's own workspace and provider, never a caller's", async () => { + // The security property: a client sends only a session id, so the tab cannot be aimed at + // another workspace by asking for one. + const stored = record('claude', 'workspace-from-record') + + await expect( + revealStructuredAgentSession( + { store: { getRecord: () => stored } as never, adapter: {} as never }, + 'session-answered', + () => true, + async () => true + ) + ).resolves.toEqual({ + sessionId: 'session-answered', + workspaceId: 'workspace-from-record', + agent: 'claude', + readable: true + }) + }) + + it('refuses a session this host holds no record for', async () => { + await expect( + revealStructuredAgentSession( + { store: { getRecord: () => null } as never, adapter: {} as never }, + 'session-absent', + () => false, + async () => false + ) + ).rejects.toThrow('agent_session_identity_required') + }) + + it('refuses a record no adapter of this host supports', async () => { + vi.mocked(providerSupport.adapterSupportsRecord).mockReturnValue(false) + + await expect( + revealStructuredAgentSession( + { + store: { getRecord: () => record('codex', 'workspace-1') } as never, + adapter: {} as never + }, + 'session-answered', + () => false, + async () => false + ) + ).rejects.toThrow('structured_agent_session_unsupported') + }) + + it('answers not-readable without refusing when the journal could not be restored', async () => { + // The tab is still worth publishing: attach recovers what read restore cannot. + await expect( + revealStructuredAgentSession( + { + store: { getRecord: () => record('codex', 'workspace-1') } as never, + adapter: {} as never + }, + 'session-answered', + () => false, + async () => false + ) + ).resolves.toMatchObject({ readable: false, agent: 'codex' }) + }) + + it('does not restore over a session that is already live', async () => { + const restoreReadable = vi.fn(async () => true) + + await expect( + revealStructuredAgentSession( + { + store: { getRecord: () => record('codex', 'workspace-1') } as never, + adapter: {} as never + }, + 'session-answered', + () => true, + restoreReadable + ) + ).resolves.toMatchObject({ readable: true }) + expect(restoreReadable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts new file mode 100644 index 00000000000..41774a9d719 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -0,0 +1,80 @@ +// Making a persisted chat addressable again, for a surface that can no longer see it. +// +// `close` keeps the record and the journal on disk precisely so a session can be attached again; +// what it does not keep is the tab, and a client drops every unpublished `agent-session` tab on +// each session-tabs sync. So a chat the user closed — or one this process has not opened since +// launch — is reachable in Agent Session History by id and by nothing else. This is the lookup that +// turns that id back into something a client can publish. +// +// It is deliberately the whole of what reveal does on the host. It takes no hold: a provider child +// exists because a surface asked for one, and the chat pane asks when it binds. And a journal it +// cannot read is not a refusal — a chat whose journal predates the SQLite store restores to nothing +// here, yet attach still recovers it, so the tab is worth publishing either way. + +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionReveal +} from './structured-agent-session-host-types' + +/** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ +export async function revealStructuredAgentSession( + deps: Pick, + sessionId: string, + hasSession: (sessionId: string) => boolean, + restoreReadable: (sessionId: string) => Promise +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + if (!adapterSupportsRecord(deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } + // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a + // contested or reconciling chat still reveals and the hold that follows adjudicates it. Refusing + // here would hide the one view of a session a user needs when its ownership is in doubt. + const readable = hasSession(sessionId) || (await restoreReadable(sessionId)) + return { + sessionId, + // From the record, never from a caller: a client that knows only a session id must not be able + // to aim the tab publication at another workspace. + workspaceId: record.location.workspaceId, + agent: record.provider, + readable + } +} + +/** + * The host's whole readable-restore surface: the startup sweep and the on-demand reveal. + * + * Bundled the way the handoff and lifetime collaborators are, because the two share the restorer + * and differ only in who is asking — startup, once, for everything; a surface, later, for one. + */ +export function createStructuredAgentSessionHostRestore( + deps: StructuredAgentSessionHostDeps, + wiring: Omit< + ConstructorParameters[0], + 'store' | 'journalRoot' | 'supportsRecord' + > +): { + restoreReadableSessions: (sessionIds?: readonly string[]) => Promise + revealSession: (sessionId: string) => Promise +} { + const restorer = new StructuredAgentSessionReadableRestorer({ + store: deps.store, + journalRoot: deps.journalRoot, + supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + ...wiring + }) + const gate = new StructuredAgentSessionRestartRestoreGate() + return { + restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)), + revealSession: (sessionId) => + revealStructuredAgentSession(deps, sessionId, wiring.hasSession, (id) => + restorer.restoreOne(id) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts new file mode 100644 index 00000000000..7efd147c420 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'status-session' +const TURN_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const +const USER_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function openJournal(sessionId = SESSION) { + return journals.open({ + identity: { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, sessionId) + }) +} + +function indexed(session: { journal: Awaited> }) { + return { + journal: session.journal, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } + } +} + +function feedFor(sessions: Map> }>) { + let now = 1_000 + const feed = new StructuredAgentSessionStatusFeed({ + sessions: { + get: (sessionId: string) => { + const session = sessions.get(sessionId) + return session ? indexed(session) : undefined + }, + [Symbol.iterator]: function* () { + for (const [sessionId, session] of sessions) { + yield [sessionId, indexed(session)] as const + } + } + } as unknown as ReadonlyMap>, + getRecord: () => null, + now: () => (now += 1) + }) + const events: AgentSessionStatusEvent[] = [] + const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) + return { feed, events, dispose } +} + +describe('StructuredAgentSessionStatusFeed', () => { + it('opens with every readable session and reports no status before a persisted turn', async () => { + const journal = await openJournal() + const { events } = feedFor(new Map([[SESSION, { journal }]])) + + expect(events).toEqual([ + { + type: 'snapshot', + sessions: [ + { + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: null, + latestPrompt: '', + updatedAt: expect.any(Number) + } + ] + } + ]) + }) + + it('publishes working, then idle once the running marker is tombstoned, and never a repeat', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + + feed.publish(SESSION) + feed.publish(SESSION) + expect(events.slice(1)).toEqual([ + { + type: 'status', + session: expect.objectContaining({ + sessionId: SESSION, + status: 'working', + latestPrompt: 'write a poem' + }) + } + ]) + + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) + expect(events).toHaveLength(3) + }) + + it('reports a pending approval as attention', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'attention' }) + }) + }) + + it('keeps the last projection for an evicted session and serves it to a new subscriber', async () => { + const journal = await openJournal() + const sessions = new Map([[SESSION, { journal }]]) + const { feed, events } = feedFor(sessions) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) + }) + + // Eviction drops the host's index entry; the projection it already made stays true. + sessions.delete(SESSION) + feed.publish(SESSION) + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-late', emit: (event) => late.push(event) }) + + expect(events).toHaveLength(2) + expect(late).toEqual([ + { + type: 'snapshot', + sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })] + } + ]) + }) + + it('tells the sitting subscribers about a change a new subscriber re-projected', async () => { + const journal = await openJournal() + const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) + // Journal appends and the feed's publish are separate queue submissions, so the journal + // can already hold the turn when a second client connects and re-projects it. + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-late', emit: (event) => late.push(event) }) + + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) + }) + // The arriving subscriber reads that same state once, from its snapshot. + expect(late).toEqual([ + { + type: 'snapshot', + sessions: [expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })] + } + ]) + // The cache is not left holding a value nobody was told about. + feed.publish(SESSION) + expect(events).toHaveLength(2) + }) + + it('ends a closed subscriber and keeps publishing to the rest', async () => { + const journal = await openJournal() + const { feed, events, dispose } = feedFor(new Map([[SESSION, { journal }]])) + const others: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-2', emit: (event) => others.push(event) }) + + dispose() + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION) + + expect(events.at(-1)).toEqual({ type: 'end' }) + expect(others.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle' }) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts new file mode 100644 index 00000000000..5ad494cf830 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -0,0 +1,130 @@ +// The host's answer to "what is every structured session doing", fanned out to session lists. +// +// A client used to learn whether a turn was running by replaying the journal through its own +// reducer, which tied the answer to whichever surface happened to hold a reader open: hide the +// chat and the sidebar froze on the last thing it had heard. The host always has the journal, so +// it projects the status once per journal publication and sends only the changes. +// +// The last projection is kept after the session's provider child is evicted: an idle session is +// still idle without a process, and a renderer that reloads must not lose every settled row until +// each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions +// republishes them. + +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' + +export type StructuredAgentSessionStatusSubscriber = { + id: string + emit: (event: AgentSessionStatusEvent) => void +} + +type StatusFeedSession = { + journal: AgentSessionJournal + params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } +} + +export type StructuredAgentSessionStatusFeedDeps = { + sessions: ReadonlyMap + getRecord: (sessionId: string) => AgentSessionRecord | null + now: () => number +} + +function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { + return ( + a.workspaceId === b.workspaceId && + a.agent === b.agent && + a.status === b.status && + a.latestPrompt === b.latestPrompt && + agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) + ) +} + +export class StructuredAgentSessionStatusFeed { + private readonly subscribers = new Map() + private readonly published = new Map() + + constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} + + /** Opens with every session this host has projected, live ones re-read, then only changes. */ + subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void { + // Re-project before registering: a change found here has to reach the subscribers that + // already read the old value, and the arriving one carries it in its snapshot instead. + for (const [sessionId] of this.deps.sessions) { + this.publish(sessionId) + } + this.subscribers.set(subscriber.id, subscriber) + this.emit(subscriber, { type: 'snapshot', sessions: [...this.published.values()] }) + return () => this.unsubscribe(subscriber.id) + } + + unsubscribe(id: string): void { + const subscriber = this.subscribers.get(id) + if (!subscriber) { + return + } + this.subscribers.delete(id) + try { + subscriber.emit({ type: 'end' }) + } catch { + // The transport is already gone; teardown must remain idempotent. + } + } + + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ + publish(sessionId: string, journal?: AgentSessionJournal): void { + const session = this.deps.sessions.get(sessionId) + if (!session) { + return + } + const summary = this.summaryFor(sessionId, session, journal ?? session.journal) + const previous = this.published.get(sessionId) + if (previous && summariesEqual(previous, summary)) { + return + } + this.published.set(sessionId, summary) + this.broadcast({ type: 'status', session: summary }) + } + + private summaryFor( + sessionId: string, + session: StatusFeedSession, + journal: AgentSessionJournal + ): AgentSessionStatusSummary { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const items = journal.isReadOnly ? [] : journal.snapshot().items + const providerSession = structuredAgentSessionProviderSessionMetadata( + this.deps.getRecord(sessionId) + ) + return { + sessionId, + workspaceId: session.params.location.workspaceId, + agent: session.params.provider, + ...projectStructuredAgentSessionStatusSummary(items), + ...(providerSession ? { providerSession } : {}), + updatedAt: this.deps.now() + } + } + + private broadcast(event: AgentSessionStatusEvent): void { + // A Map skips entries deleted mid-iteration, so a failing subscriber can drop itself here. + for (const subscriber of this.subscribers.values()) { + this.emit(subscriber, event) + } + } + + /** A dead transport must not poison every later publication. */ + private emit(subscriber: StructuredAgentSessionStatusSubscriber, event: AgentSessionStatusEvent) { + try { + subscriber.emit(event) + } catch { + this.subscribers.delete(subscriber.id) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 6e156d91532..81bcfa82b40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import type { AgentSessionHandoffStatus, + AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { @@ -16,6 +17,7 @@ import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' const SESSION = 'subscriber-session' @@ -70,6 +72,96 @@ describe('AgentSessionSubscribers', () => { ]) }) + it('reports every content publication to the journal hook, subscribed or not', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'hook-journal') + }) + const published: string[] = [] + const subscribers = new AgentSessionSubscribers({ + onJournalPublished: (sessionId, published_journal) => { + expect(published_journal).toBe(journal) + published.push(sessionId) + } + }) + + subscribers.publish(SESSION, journal) + subscribers.reset(SESSION, journal, 'epoch_changed', 1) + subscribers.snapshot(SESSION, journal, 1) + subscribers.handoff(SESSION, 1, { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + }) + + expect(published).toEqual([SESSION, SESSION, SESSION]) + }) + + it('settles a session nobody is reading, from running to idle', async () => { + // The defect this whole feed exists for: status used to come from a transcript reader, so a + // session with no open pane had no reader and froze on whatever it last said. Nothing here + // ever calls `subscribers.open`. + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'unread-journal') + }) + const statusFeed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + SESSION, + { journal, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' } } + ] + ]), + getRecord: () => null, + now: () => 1_000 + }) + const subscribers = new AgentSessionSubscribers({ + onJournalPublished: (sessionId, published) => statusFeed.publish(sessionId, published) + }) + const statuses: AgentSessionStatusEvent[] = [] + statusFeed.subscribe({ id: 'session-list', emit: (event) => statuses.push(event) }) + const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } as const + + await journal.appendItem( + { ...turn, ordinal: 1 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, + { fence: 1 } + ) + await journal.appendItem( + turn, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + subscribers.publish(SESSION, journal) + + expect(statuses.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'working', latestPrompt: 'write a poem' }) + }) + + await journal.appendTombstone(turn, { fence: 1 }) + subscribers.publish(SESSION, journal) + + expect(statuses.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle' }) + }) + }) + it('publishes handoff-only changes without serializing a transcript snapshot', async () => { const journal = await journals.open({ identity: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index d3131505384..29dffa6a687 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -36,9 +36,17 @@ type Subscriber = { fence: number } +export type AgentSessionSubscribersHooks = { + /** Fires after any publication that can change journal content, whether or not anyone + * is subscribed to the transcript: session lists project status from this same edge. */ + onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void +} + export class AgentSessionSubscribers { private readonly bySession = new Map>() + constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {} + /** Opens the stream with a bounded tail page or, when the client's cursor * still resolves, with the rows it missed. Returns the disposer. */ open(input: { @@ -99,6 +107,7 @@ export class AgentSessionSubscribers { for (const subscriber of this.subscribers(sessionId)) { this.deliver(subscriber, journal) } + this.hooks.onJournalPublished?.(sessionId, journal) } /** Force every subscriber back to a bounded tail page — recovery, epoch @@ -123,6 +132,7 @@ export class AgentSessionSubscribers { subscriber.cursor = page.liveCursor ?? page.window.nextCursor subscriber.fence = fence } + this.hooks.onJournalPublished?.(sessionId, journal) } snapshot( @@ -143,6 +153,7 @@ export class AgentSessionSubscribers { subscriber.cursor = page.liveCursor ?? page.window.nextCursor subscriber.fence = fence } + this.hooks.onJournalPublished?.(sessionId, journal) } handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index e8e6f998bdd..31df2c44551 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -104,4 +104,40 @@ describe('performCancel', () => { expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) }) + + it('routes one background task id without interrupting the foreground turn or writing a row', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-')) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const cancelTurn = vi.fn(async () => ({ cancelled: true })) + const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) + const ctx: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + + const result = await performCancel(ctx, { + clientOperationId: 'cancel-background-task-2', + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-2' + }) + + expect(result).toEqual({ + ok: true, + value: { turnId: 'background-tasks', cancelled: true } + }) + expect(stopBackgroundTasks).toHaveBeenCalledWith({ + sessionId: 'session-1', + fence: 1, + taskId: 'task-2' + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(journal.snapshot().items).toEqual([]) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 76c4e8e89d6..3bd98a61735 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -150,6 +150,7 @@ export async function performCancel( clientOperationId: string turnId: string scope?: 'background-tasks' + taskId?: string } ): Promise> { let cancelled = false @@ -159,7 +160,8 @@ export async function performCancel( ? ( await ctx.adapter.stopBackgroundTasks?.({ sessionId: ctx.sessionId, - fence: ctx.fence + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) }) )?.cancelled === true : ( diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index f891da51418..74728a2d81b 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -48,7 +48,8 @@ import { type UploadBundleOptions, type UploadBundleResult } from './diagnostic-bundle-upload' -import { setActiveSink } from './tracer' +import { setActiveSink, startSpan } from './tracer' +import { setSecurePathHardeningReporter } from '../../shared/secure-path-hardening-report' const CI_ENV_VARS = [ 'CI', @@ -153,10 +154,37 @@ export function initObservability(): ObservabilityConsent { return c } installLocalSink() + installSecurePathHardeningReporter() return c } +/** + * Why route it here: Windows path hardening lives in `src/shared` and defaults to `console.warn`, + * which reaches nothing in a packaged build — the main process is GUI-subsystem and owns no + * console. A credential file left on inherited ACLs is exactly what a diagnostic bundle should + * show, so it becomes a span in the trace sink. + * + * `recovered` ends successfully rather than failing: a host that climbs back out of the + * rate-limited state has to be as visible as one that fell into it, or the degraded state is only + * ever half-diagnosable. + */ +function installSecurePathHardeningReporter(): void { + setSecurePathHardeningReporter((entry) => { + const span = startSpan('secure-path.windows-acl', { + attributes: { targetPath: entry.targetPath, stage: entry.stage, detail: entry.detail } + }) + if (entry.stage === 'recovered') { + span.end() + console.info('[secure-path.windows-acl] path hardening recovered', entry) + return + } + span.fail(entry.detail) + console.warn('[secure-path.windows-acl] failed to restrict path', entry) + }) +} + export async function shutdownObservability(): Promise { + setSecurePathHardeningReporter(null) // Order matters: tracer first so no new pushes arrive while the local sink // is closing and flushing buffered lines. setActiveSink(null) diff --git a/src/main/observability/redactor-environment-lines.test.ts b/src/main/observability/redactor-environment-lines.test.ts new file mode 100644 index 00000000000..a7ab3141d69 --- /dev/null +++ b/src/main/observability/redactor-environment-lines.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { redactString } from './redactor' + +const originalEnvLine = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*\S.*/gm +const original = (input: string): string => + input.replace(originalEnvLine, (_match, key) => `${String(key)}=[redacted:env-value]`) + +const whitespace = [ + ' ', + '\t', + '\n', + '\r', + '\r\n', + '\u2028', + '\u2029', + '\v', + '\f', + '\u00a0', + '\ufeff' +] + +describe('environment line redaction parity', () => { + it.each(whitespace)('preserves cross-line greedy whitespace for %j', (space) => { + for (const source of [ + `${space}${space}FOO${space}=${space}value${space}BAR=next`, + `FOO=${space}${space}`, + `${space}${space}not-a-key${space}FOO=value`, + `FOO${space}${space}BAR=value`, + `FOO=${space}BAR=next`, + `${space}FOO=one${space}${space}BAR=two${space}`, + `not a key${space}${space}FOO=one`, + `${space}FOO=${space}${space}lowercase`, + `${space}FOO=${space} BAR=next${space}tail` + ]) { + expect(redactString(source), JSON.stringify(source)).toBe(original(source)) + } + }) + + it('matches the original across 20,000 malformed and valid sequences', () => { + const tokens = [...whitespace, 'FOO', '_A12', 'lowercase', '!', '=', '==', 'value', '0', 'FOO='] + let seed = 173 + for (let sample = 0; sample < 20000; sample++) { + let source = '' + for (let token = 0; token < 18; token++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + source += tokens[seed % tokens.length] + } + expect(redactString(source), JSON.stringify(source)).toBe(original(source)) + } + }) + + it('keeps rule ordering for labeled secrets within environment values', () => { + expect(redactString('\n\r\nFOO=token: value\nBAR=other')).toBe( + 'FOO=[redacted:env-value]\nBAR=[redacted:env-value]' + ) + }) + + it('does not retry every blank line before an invalid key', () => { + const source = `${'\r\n\u2028\u2029'.repeat(16384)}lowercase` + const started = performance.now() + expect(redactString(source)).toBe(source) + expect(performance.now() - started).toBeLessThan(200) + }) +}) diff --git a/src/main/observability/redactor.ts b/src/main/observability/redactor.ts index da8feb0a8b0..7ddab42a0c9 100644 --- a/src/main/observability/redactor.ts +++ b/src/main/observability/redactor.ts @@ -39,7 +39,11 @@ const PROVIDER_PATTERNS: { tag: string; re: RegExp }[] = [ const URL_USERINFO = /(https?:\/\/)([^/@\s]+)@/g // Per-line .env shape. `m` anchors `^` in multi-line strings; `\S.*` redacts the whole value (so `FOO=Bearer ` can't leak its tail), leading `\S` skips empty `FOO=`. -const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*\S.*/gm +const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*\S.*/my +// Exact `\s*` for the non-ASCII whitespace runs the inline scan hands off (NBSP, BOM, U+2028\u2026). +const WHITESPACE_RUN = /\s*/y +// The terminator set `.` and `^`/m use; scanned with `indexOf` so no-match lines never enter the regex engine. +const LINE_TERMINATORS = ['\n', '\r', '\u2028', '\u2029'] as const // Attribute keys dropped regardless of value. Matched case-insensitively since HTTP headers vary in case. const CLIENT_ATTR_BLOCKLIST = new Set([ @@ -110,11 +114,79 @@ export function redactString(input: string): string { out = out.replace(URL_USERINFO, '$1[redacted]@') // Rule 4 — .env-shape line: keep key, redact value. Last so rule 1 wins over a coincidentally .env-shaped substring. - out = out.replace(ENV_LINE, (_match, key) => `${String(key)}=[redacted:env-value]`) + out = redactEnvironmentLines(out) return out } +/** Index of the first non-`\s` code unit at or after `index`, or `input.length`. ASCII is the spec-fixed set; anything else defers to the engine. */ +function skipWhitespace(input: string, index: number): number { + const length = input.length + while (index < length) { + const code = input.charCodeAt(index) + if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) { + index++ + } else if (code < 0x80) { + return index + } else { + WHITESPACE_RUN.lastIndex = index + WHITESPACE_RUN.test(input) + if (WHITESPACE_RUN.lastIndex === index) { + return index + } + index = WHITESPACE_RUN.lastIndex + } + } + return index +} + +function redactEnvironmentLines(input: string): string { + const parts: string[] = [] + let copiedThrough = 0 + let start = 0 + // Each terminator kind is searched at most once per occurrence; -2 marks "not searched yet", -1 "none remain". + const nextTerminator = [-2, -2, -2, -2] + while (start < input.length) { + const content = skipWhitespace(input, start) + if (content === input.length) { + break + } + // Only a `[A-Z_]` first content char can match; failed starts within the leading whitespace all see the same key. + const code = input.charCodeAt(content) + if ((code >= 0x41 && code <= 0x5a) || code === 0x5f) { + ENV_LINE.lastIndex = start + const match = ENV_LINE.exec(input) + if (match) { + parts.push(input.slice(copiedThrough, start), `${match[1]}=[redacted:env-value]`) + copiedThrough = ENV_LINE.lastIndex + // `.*` stops at end of input or a line terminator, so the next line starts one past it. + start = copiedThrough + 1 + continue + } + } + let terminator = -1 + for (let kind = 0; kind < LINE_TERMINATORS.length; kind++) { + let next = nextTerminator[kind]! + if (next !== -1 && next < content) { + next = input.indexOf(LINE_TERMINATORS[kind]!, content) + nextTerminator[kind] = next + } + if (next !== -1 && (terminator === -1 || next < terminator)) { + terminator = next + } + } + if (terminator === -1) { + break + } + start = terminator + 1 + } + if (parts.length === 0) { + return input + } + parts.push(input.slice(copiedThrough)) + return parts.join('') +} + /** * Recursively redact a value of unknown shape (strings get rules 1–4; containers * recurse; primitives pass through). The `seen` WeakSet guards against cycles, diff --git a/src/main/orca-profiles/profile-cloud-auth-status.ts b/src/main/orca-profiles/profile-cloud-auth-status.ts index 400498d223e..b0eb7da1978 100644 --- a/src/main/orca-profiles/profile-cloud-auth-status.ts +++ b/src/main/orca-profiles/profile-cloud-auth-status.ts @@ -29,7 +29,10 @@ export function getOrcaProfileAuthStatusFromProfile( state: 'unconfigured', persistence: session.status === 'found' ? session.persistence : 'none', cloud, - credentialError: session.status === 'decrypt-failed' ? session.error : undefined, + credentialError: + session.status === 'decrypt-failed' || session.status === 'unreadable' + ? session.error + : undefined, setupMessage: configState.setupMessage } } @@ -51,6 +54,9 @@ export function getOrcaProfileAuthStatusFromProfile( state: 'reconnect-required', persistence: 'none', cloud, - credentialError: session.status === 'decrypt-failed' ? session.error : undefined + credentialError: + session.status === 'decrypt-failed' || session.status === 'unreadable' + ? session.error + : undefined } } diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.ts b/src/main/orca-profiles/profile-cloud-session-refresh.ts index 221b4908bee..273f71f016e 100644 --- a/src/main/orca-profiles/profile-cloud-session-refresh.ts +++ b/src/main/orca-profiles/profile-cloud-session-refresh.ts @@ -72,6 +72,11 @@ function clearCloudSessionIfUnchanged( if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) { return } + // A session we were denied is not a session we may delete: the token we would be clearing might + // not even be the one that failed, and `clearOrcaCloudSession` unlinks the file outright. + if (current.status === 'unreadable') { + return + } if (active.profile.cloud) { tombstoneCloudSession( cloudSessionIdentity(active.profile.id, active.profile.cloud), diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts index 62d779772c1..d1334d96b6b 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { safeStorage } from 'electron' -import { writeSecureJsonFile } from '../../shared/secure-file' +import { isUnreadableError, writeSecureJsonFile } from '../../shared/secure-file' import type { OrcaCloudCapabilities, OrcaCloudOrgSummary, @@ -29,6 +29,11 @@ export type OrcaCloudSessionReadResult = | { status: 'found'; session: OrcaCloudSession; persistence: OrcaCloudSessionPersistence } | { status: 'missing'; persistence: 'none' } | { status: 'decrypt-failed'; persistence: 'none'; error: string } + /** + * The file is there and this process may not read it. Distinct from `decrypt-failed` because + * that one means "read it, it was garbage" and licenses replacing it; this one licenses nothing. + */ + | { status: 'unreadable'; persistence: 'none'; error: string } type PersistedEncryptedSession = { version: 1 @@ -215,7 +220,14 @@ export function readOrcaCloudSession( return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' } } return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' } - } catch { + } catch (error) { + if (isUnreadableError(error)) { + return { + status: 'unreadable', + persistence: 'none', + error: 'Cannot read the saved Orca account session: the read failed.' + } + } return { status: 'decrypt-failed', persistence: 'none', diff --git a/src/main/plugins/plugin-secrets-store.ts b/src/main/plugins/plugin-secrets-store.ts index 7f16821cfb6..fb41aba71c8 100644 --- a/src/main/plugins/plugin-secrets-store.ts +++ b/src/main/plugins/plugin-secrets-store.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { safeStorage } from 'electron' -import { writeSecureFile } from '../../shared/secure-file' +import { isUnreadableError, writeSecureFile } from '../../shared/secure-file' import { PLUGIN_STORAGE_KEY_LIMIT, PLUGIN_STORAGE_TOTAL_MAX_BYTES @@ -25,6 +25,8 @@ type PersistedSecretsFile = { export type PluginSecretsResult = { ok: true; value: T } | { ok: false; error: string } +const UNREADABLE_VAULT_ERROR = 'secret vault exists but could not be read; refusing to overwrite it' + export class PluginSecretsStore { private readonly filePath: string @@ -32,7 +34,8 @@ export class PluginSecretsStore { this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), 'secrets.json.enc') } - private read(): PersistedSecretsFile { + /** `null` means the vault exists and this process may not read it — which is never "empty". */ + private read(): PersistedSecretsFile | null { const empty: PersistedSecretsFile = { version: 1, format: 'electron-safe-storage-v1', @@ -56,7 +59,13 @@ export class PluginSecretsStore { ) { return parsed } - } catch { + } catch (error) { + // Being denied the read is not evidence the vault is corrupt. Returning `empty` here would + // make the next set() write a vault containing only that one key, silently dropping every + // secret the file still holds — and the write would succeed. + if (isUnreadableError(error)) { + return null + } // Corrupt vaults read as empty; set() rewrites a valid file. } return empty @@ -64,6 +73,9 @@ export class PluginSecretsStore { get(key: string): PluginSecretsResult { const file = this.read() + if (!file) { + return { ok: false, error: UNREADABLE_VAULT_ERROR } + } const ciphertext = file.ciphertexts[key] if (typeof ciphertext !== 'string') { return { ok: true, value: null } @@ -83,6 +95,9 @@ export class PluginSecretsStore { return { ok: false, error: 'OS-backed encryption is unavailable; secret not stored' } } const file = this.read() + if (!file) { + return { ok: false, error: UNREADABLE_VAULT_ERROR } + } if ( !Object.hasOwn(file.ciphertexts, key) && Object.keys(file.ciphertexts).length >= PLUGIN_STORAGE_KEY_LIMIT @@ -100,6 +115,10 @@ export class PluginSecretsStore { delete(key: string): void { const file = this.read() + if (!file) { + // Rewriting what we could not read would drop every other secret in the vault. + return + } if (Object.hasOwn(file.ciphertexts, key)) { delete file.ciphertexts[key] writeSecureFile(this.filePath, JSON.stringify(file, null, 2)) diff --git a/src/main/plugins/plugin-storage-store.ts b/src/main/plugins/plugin-storage-store.ts index b3949b8f6d4..94a74261e1e 100644 --- a/src/main/plugins/plugin-storage-store.ts +++ b/src/main/plugins/plugin-storage-store.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import { writeSecureFile } from '../../shared/secure-file' +import { isUnreadableError, writeSecureFile } from '../../shared/secure-file' import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' import { PLUGIN_STORAGE_KEY_LIMIT, @@ -16,6 +16,8 @@ import { * Adapted from community PR #5801's per-plugin settings store. */ +const UNREADABLE_STORE_ERROR = 'storage file exists but could not be read; refusing to overwrite it' + export function pluginDataDir(pluginsDataDir: string, qualifiedKey: string): string { if (!isQualifiedPluginKey(qualifiedKey)) { throw new Error(`unsafe plugin key: ${qualifiedKey}`) @@ -36,7 +38,8 @@ export class PluginKvStore { this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), fileName) } - private read(): Record { + /** `null` means the file exists and this process may not read it - which is never `{}`. */ + private read(): Record | null { try { if (!existsSync(this.filePath)) { return {} @@ -48,22 +51,27 @@ export class PluginKvStore { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } - } catch { + } catch (error) { + // Being denied the read is not evidence of corruption. Returning `{}` here would make + // the next set()/delete() write a file holding only that one key, dropping the rest. + if (isUnreadableError(error)) { + return null + } // Corrupt files reset to empty rather than wedging the plugin. } return {} } get(key: string): unknown { - return this.read()[key] + return this.read()?.[key] } getAll(): Record { - return this.read() + return this.read() ?? {} } keys(): string[] { - return Object.keys(this.read()) + return Object.keys(this.read() ?? {}) } set(key: string, value: unknown): PluginKvWriteResult { @@ -80,6 +88,9 @@ export class PluginKvStore { return { ok: false, error: `value exceeds ${PLUGIN_STORAGE_VALUE_MAX_BYTES} bytes` } } const settings = this.read() + if (!settings) { + return { ok: false, error: UNREADABLE_STORE_ERROR } + } if (!Object.hasOwn(settings, key) && Object.keys(settings).length >= PLUGIN_STORAGE_KEY_LIMIT) { return { ok: false, error: `storage exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` } } @@ -94,6 +105,10 @@ export class PluginKvStore { delete(key: string): void { const settings = this.read() + if (!settings) { + // Rewriting what we could not read would drop every other key in the store. + return + } if (Object.hasOwn(settings, key)) { delete settings[key] writeSecureFile(this.filePath, JSON.stringify(settings, null, 2)) diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 7513d9c72cb..9dfa08d81bf 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -172,6 +172,7 @@ describe('LocalPtyProvider', () => { expect(second).toEqual({ id: 'serve-session-1', + incarnationId: first.incarnationId, pid: 12345, isReattach: true, // Why published: this attach really moved the PTY, unlike daemon/relay attach, so main @@ -220,7 +221,11 @@ describe('LocalPtyProvider', () => { attachOnly: true }) - expect(result).toMatchObject({ id: first.id, isReattach: true }) + expect(result).toMatchObject({ + id: first.id, + incarnationId: first.incarnationId, + isReattach: true + }) expect(spawnMock).not.toHaveBeenCalled() }) diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index d41f858cf98..2ab145f6c39 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -1,6 +1,7 @@ import type { PtySpawnResult } from './types' import { pendingLocalPtySpawns, + ptyIncarnations, ptyProcesses, ptyWslDistroById, type PendingLocalPtySpawn @@ -60,6 +61,7 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa } return { id, + ...(ptyIncarnations.has(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), pid: existing.pid, ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), isReattach: true, diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts index 924c81789ce..42330dd6fe8 100644 --- a/src/main/providers/windows-foreground-process-rows.test.ts +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -12,9 +12,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const getAllProcessesMock = vi.fn() -import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { + __setWindowsProcessTreeLoaderForTests, + readWindowsProcessIdentityTable +} from '../windows/windows-process-table' import { queryWindowsProcessDescendants, + queryWindowsProcessLinksFresh, queryWindowsProcessRowsFresh, resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' @@ -117,4 +121,25 @@ describe('windows process rows', () => { expect(scanCount()).toBe(2) }) + + it('never answers the ancestry links from the identity TTL cache either', async () => { + // The identity table is a second reader with its own TTL, so the freshness + // the ancestry walk depends on has to be pinned on its own. + await readWindowsProcessIdentityTable() + getAllProcessesMock.mockImplementation((cb: (rows: unknown) => void) => { + cb(withSelf([{ pid: 300, ppid: 100, name: 'node.exe' }])) + }) + // Proves the cache the fresh read below ignores is live, not merely expired. + expect((await readWindowsProcessIdentityTable()).map((row) => row.pid)).toEqual([ + process.pid, + 100, + 200 + ]) + expect(scanCount()).toBe(1) + + const links = await queryWindowsProcessLinksFresh() + + expect(scanCount()).toBe(2) + expect(links.map((row) => row.pid)).toEqual([process.pid, 300]) + }) }) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index e8320a6d00a..16f01d5fbe7 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -1,8 +1,10 @@ import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' import { + readWindowsProcessIdentityTableFresh, readWindowsProcessTable, readWindowsProcessTableFresh, resetWindowsProcessTableForTests, + type WindowsProcessIdentityRow, type WindowsProcessRow as NativeWindowsProcessRow } from '../windows/windows-process-table' @@ -62,6 +64,16 @@ export async function queryWindowsProcessRowsFresh(): Promise { + return readWindowsProcessIdentityTableFresh() +} + export async function queryWindowsProcessDescendants( rootPid: number, options: { fresh?: boolean } = {} diff --git a/src/main/repo-icon-file-detection.test.ts b/src/main/repo-icon-file-detection.test.ts index 820b2edddfc..19c895e6024 100644 --- a/src/main/repo-icon-file-detection.test.ts +++ b/src/main/repo-icon-file-detection.test.ts @@ -1,4 +1,6 @@ -import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import type * as FsPromisesModule from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch' @@ -136,3 +138,55 @@ describe('detectRepoFileIcon connection boundary', () => { expect(stat).toHaveBeenCalled() }) }) + +describe('declared repo icons through production filesystem routes', () => { + it.each([ + ['local', false], + ['ssh', false], + ['local', true], + ['ssh', true] + ] as const)('preserves declared icon detection on %s (no icon: %s)', async (kind, noIcon) => { + const directory = await mkdtemp(join(tmpdir(), 'orca-icon-href-')) + const source = noIcon + ? 'a'.repeat(256 * 1024) + : `${'a'.repeat(32768)}{ rel: "icon", href: "/first.png", href: "/chosen.png" }` + try { + await mkdir(join(directory, 'public')) + await writeFile(join(directory, 'index.html'), source) + await writeFile(join(directory, 'public', 'chosen.png'), Buffer.from(PNG_BASE64, 'base64')) + const provider = remoteFilesystemProvider({ + stat: async (path) => { + const info = await stat(path) + return { + type: info.isFile() ? 'file' : 'directory', + size: info.size, + mtime: info.mtimeMs + } + }, + readFile: async (path) => { + const buffer = await readFile(path) + const isBinary = path.endsWith('.png') + return { + content: buffer.toString(isBinary ? 'base64' : 'utf8'), + isBinary, + mimeType: isBinary ? 'image/png' : 'text/html' + } + } + }) + const route: ExecutionHostFilesystemRoute = + kind === 'local' ? { kind: 'local', hostId: 'local' } : sshRoute('icon-oracle', provider) + await expect(detectRepoFileIcon(directory, route)).resolves.toEqual( + noIcon + ? null + : { + type: 'image', + src: `data:image/png;base64,${PNG_BASE64}`, + source: 'file', + label: 'public/chosen.png' + } + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts index f4289924b08..831248b94b3 100644 --- a/src/main/repo-icon-file-detection.ts +++ b/src/main/repo-icon-file-detection.ts @@ -3,6 +3,7 @@ import { buildImageDataUri } from '../shared/image-data-uri' import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' import type { ExecutionHostFilesystemRoute } from './providers/execution-host-provider-dispatch' import type { IFilesystemProvider } from './providers/types' +import { extractIconHref } from './repo-icon-source-href' import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' @@ -49,11 +50,6 @@ const REPO_ICON_SOURCE_FILE_CANDIDATES = [ // not read large app entrypoints just to find a small favicon href. const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i -const LINK_ICON_OBJECT_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i - type DetectedImageFormat = { mimeType: 'image/png' | 'image/webp' } @@ -98,10 +94,6 @@ function detectImageFormat(buffer: Buffer): DetectedImageFormat | null { return null } -function extractIconHref(source: string): string | null { - return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null -} - function repoIconFromImageBuffer(buffer: Buffer, relativePath: string): RepoIcon | null { const format = detectImageFormat(buffer) if (!format) { diff --git a/src/main/repo-icon-source-href.test.ts b/src/main/repo-icon-source-href.test.ts new file mode 100644 index 00000000000..7c74511f3d6 --- /dev/null +++ b/src/main/repo-icon-source-href.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { extractIconHref } from './repo-icon-source-href' + +// Original production expressions are the compatibility oracle. +const HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i + +export function originalIconHref(source: string): string | null { + return source.match(HTML_RE)?.[1] ?? source.match(OBJECT_RE)?.[1] ?? null +} + +describe('repo icon source href compatibility', () => { + it.each([ + '', + '', + '', + '', + '', + '', + 'plain source without icon properties', + '{ rel: "icon", href: "/first.png", href: "/last.png" }', + '{ href: "/first.png", rel: "icon", rel: "stylesheet" }', + '{ rel: "icon" } { href: "/unrelated.png" }', + '{ rel: "icon", href: "/first.png" } { rel: "icon", href: "/last.png" }', + '{ rel: "icon", href: "/object.png" } ', + '{ rel: "ICON", href: "/UPPER.png" }', + '}\n\n{href: "/line.png",\nrel : "shortcut icon"}', + '{ rel: "icon", href: "/unterminated}after brace', + '{ rel: "icon", href: "?query" }', + '{ rel: "icon", href: "/before?query" }', + '', + ' { + expect(extractIconHref(source)).toBe(originalIconHref(source)) + }) + + it('matches the original across generated malformed property sequences', () => { + const tokens = [ + '}', + '{', + ' ', + 'rel:"icon"', + 'href:"a"', + 'href:"b"', + 'rel:"other"', + 'x', + '\n', + '', + 'rel="icon"', + 'href="a"', + 'href="b"' + ] + let seed = 97 + for (let sample = 0; sample < 3000; sample++) { + let source = '' + for (let token = 0; token < 12; token++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + source += tokens[seed % tokens.length] + } + expect(extractIconHref(source), source).toBe(originalIconHref(source)) + } + }) + + it('handles a maximum-size unterminated HTML tag region', () => { + expect(extractIconHref(' { + expect(extractIconHref('a'.repeat(256 * 1024))).toBeNull() + }) +}) diff --git a/src/main/repo-icon-source-href.ts b/src/main/repo-icon-source-href.ts new file mode 100644 index 00000000000..b8363da3711 --- /dev/null +++ b/src/main/repo-icon-source-href.ts @@ -0,0 +1,46 @@ +const LINK_START_RE = /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/iy +const LINK_ICON_OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/iy + +function extractHtmlIconHref(source: string): string | null { + LINK_START_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = LINK_START_RE.exec(source))) { + LINK_ICON_HTML_RE.lastIndex = match.index + const href = LINK_ICON_HTML_RE.exec(source)?.[1] + if (href !== undefined) { + return href + } + // Later link starts before the same closing angle see only a subset of these attributes. + const closingAngle = source.indexOf('>', LINK_START_RE.lastIndex) + if (closingAngle === -1) { + return null + } + LINK_START_RE.lastIndex = closingAngle + 1 + } + return null +} + +export function extractIconHref(source: string): string | null { + const htmlHref = extractHtmlIconHref(source) + if (htmlHref !== null) { + return htmlHref + } + let start = 0 + while (start <= source.length) { + // Every suffix before the next closing brace sees the same candidate properties. + LINK_ICON_OBJECT_RE.lastIndex = start + const href = LINK_ICON_OBJECT_RE.exec(source)?.[1] + if (href !== undefined) { + return href + } + const closingBrace = source.indexOf('}', start) + if (closingBrace === -1) { + return null + } + start = closingBrace + 1 + } + return null +} diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 51577048d31..5d441782ca8 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -15,7 +15,10 @@ import type { } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { runProcess } from '../../shared/child-process/run-process' -import { readWindowsProcessTableFresh } from '../windows/windows-process-table' +import { + isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTableFresh +} from '../windows/windows-process-table' /** Start times drift by scheduler granularity and clock reads; compare with a tolerance. */ export const PROCESS_START_TIME_TOLERANCE_MS = 2_000 @@ -111,8 +114,17 @@ async function readDarwinProcessStartTimesMs( } async function readWindowsProcessStartTimeMs(pid: number): Promise { + // No shipped addon build exposes the creation-time flag, so without this the + // whole table gets scanned to produce `null` every time. + if (!isWindowsProcessStartTimeAvailable()) { + return null + } try { - const row = (await readWindowsProcessTableFresh()).find((candidate) => candidate.pid === pid) + // Identity flag set: only the creation time is read, so no command line is + // worth an `OpenProcess` per process here. + const row = (await readWindowsProcessIdentityTableFresh()).find( + (candidate) => candidate.pid === pid + ) return row?.creationTimeMs ?? null } catch { return null diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 2e42d595b04..e9cba45ffa9 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -599,6 +599,46 @@ describe('a structured Claude session over agentSession.*', () => { `claude:${PROVIDER_SESSION}:assistant-leaf` ) + claude.live().handlers.onMessage?.({ + type: 'system', + subtype: 'background_tasks_changed', + session_id: PROVIDER_SESSION, + uuid: 'background-roster', + tasks: [ + { task_id: 'task-one', task_type: 'local_agent', description: 'First task' }, + { task_id: 'task-two', task_type: 'local_bash', description: 'Second task' } + ] + }) + const itemsBeforeTaskStop = itemsOf(stream) + const targetedStopFields = { + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-two' + } + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', targetedStopFields, created.fence), + ...targetedStopFields + }) + ).resolves.toMatchObject({ turnId: 'background-tasks', cancelled: true }) + expect(claude.live().calls.filter((entry) => entry.subtype === 'stop_task')).toEqual([ + { subtype: 'stop_task', params: { taskId: 'task-two' } } + ]) + expect(itemsOf(stream)).toEqual(itemsBeforeTaskStop) + + const staleStopFields = { + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-stale' + } + await expect( + ok('agentSession.cancel', { + envelope: envelope('agentSession.cancel', staleStopFields, created.fence), + ...staleStopFields + }) + ).resolves.toMatchObject({ turnId: 'background-tasks', cancelled: false }) + expect(claude.live().calls.filter((entry) => entry.subtype === 'stop_task')).toHaveLength(1) + const answeredPermission = Promise.resolve( claude.live().handlers.canUseTool?.('Bash', { command: 'ls' }, { requestId: 'permission-1', diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index b5f0c7a6c8d..b2d5de8ef41 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -5,7 +5,11 @@ import { randomBytes, randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import { + hardenExistingSecureFile, + isUnreadableError, + writeSecureJsonFile +} from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' @@ -54,6 +58,8 @@ const LAST_SEEN_FLUSH_DELAY_MS = 250 export class DeviceRegistry { private readonly registryPath: string private devices: DeviceEntry[] = [] + /** Set when the registry exists but could not be read, which makes `devices` a lie to save from. */ + private registryUnreadable = false private pendingLastSeenFlush: NodeJS.Timeout | null = null constructor(userDataPath: string) { @@ -293,12 +299,21 @@ export class DeviceRegistry { // LAN links), so a missing value must keep binding every interface on reconnect. pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network' })) - } catch { + this.registryUnreadable = false + } catch (error) { + // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely + // denied would erase every paired device's bearer token, and the write would succeed. + this.registryUnreadable = isUnreadableError(error) this.devices = [] } } private save(devices: DeviceEntry[]): void { + if (this.registryUnreadable) { + throw new Error( + `Cannot read the device registry at ${this.registryPath}: the read failed. Refusing to overwrite it, which would revoke every paired device.` + ) + } writeSecureJsonFile(this.registryPath, devices) // Why: every registry save includes the latest in-memory timestamps, so a later timer would rewrite it. this.cancelPendingLastSeenFlush() diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index 3e8b3e0aa0a..9ffb9844e86 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -4,7 +4,11 @@ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import nacl from 'tweetnacl' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import { + hardenExistingSecureFile, + isUnreadableError, + writeSecureJsonFile +} from '../../shared/secure-file' import { E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' const KEYPAIR_FILENAME = E2EE_KEYPAIR_FILENAME @@ -42,7 +46,17 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 } } } - } catch { + } catch (error) { + // A read this process is not permitted to make says nothing about the contents. Falling + // through would overwrite the only copy of the secret key — and the overwrite succeeds, so + // nothing downstream stops it. Every paired device derives its shared secret from this key, + // so regenerating silently un-pairs all of them and no old message stays decryptable. + if (isUnreadableError(error)) { + throw new Error( + `Cannot read the E2EE keypair at ${filePath}: the read failed. Refusing to regenerate it, which would invalidate every paired device.`, + { cause: error } + ) + } // Malformed file — regenerate below. } } diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index 11cd2e0260d..5f0b8e249d6 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -176,8 +176,17 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { expect(caches.fetchLastCompletedAt.has('/repo/cache-0::origin')).toBe(false) }) - it.each(['main', 'a'.repeat(40), 'refs/remotes/main', ''])( - 'does not launch Git for a base without a remote/branch separator: %s', + it.each([ + 'main', + 'a'.repeat(40), + 'refs/remotes/main', + '', + 'origin/', + '/main', + 'refs/remotes/origin/', + 'refs/remotes//main' + ])( + 'does not launch Git for a base without both remote and branch components: %s', async (base) => { const runtime = new OrcaRuntimeService(null) await expect(runtime.resolveRemoteTrackingBase('/repo/e', base)).resolves.toBeNull() diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 684d6699fc1..1a27de18d55 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -158,6 +158,7 @@ describe('mobile RPC allowlist', () => { 'agentSession.createSupport', 'agentSession.create', 'agentSession.ensure', + 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', 'agentSession.close', diff --git a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts index 7dcd1092625..d65209b9cc4 100644 --- a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts +++ b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts @@ -107,7 +107,10 @@ describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal- it('prepares the checkout the prefetch resolved', async () => { _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) setPlatform('win32') - mocks.prefetchWorktreeCreateBase.mockResolvedValue('origin/main') + mocks.prefetchWorktreeCreateBase.mockImplementation(async ({ prepareCheckout }) => { + await prepareCheckout('origin/main') + return 'origin/main' + }) const runtime = new OrcaRuntimeService(makeStore() as never) await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' }) diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index 1591f344bda..d9d6b2acfb9 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -50,18 +50,12 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt const repo = await this.resolveRepoSelector(args.repoSelector) const store = this.requireStore() - const baseBranch = await prefetchWorktreeCreateBase({ + await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime: this, - gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo) + gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo), + prepareCheckout: (base) => prepareWorktreeCreateForRepo(store, repo, base) }) - if (baseBranch) { - try { - await prepareWorktreeCreateForRepo(store, repo, baseBranch) - } catch { - // Why: speculative preparation is an optimistic warm-up; the real create path reports failures. - } - } } } diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index 1964b5fdd2f..2df3f7b9f17 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -153,6 +153,11 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK } } + invalidateWorktreeCatalog(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(repoId) + } + protected invalidateSshWorktreeScanCacheInternal(targetId: string): void { const repos = this.store?.getRepos() ?? [] const affectedRepos = repos.filter((repo) => getRepoSshConnectionId(repo) === targetId) diff --git a/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts index fe6e8bfe3da..a12ffabcd61 100644 --- a/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/local-worktree-creation-part-02.spec.ts @@ -50,7 +50,13 @@ describe('OrcaRuntimeService', () => { pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } }) - expect(getBranchConflictKind).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix', 'abc123') + expect(getBranchConflictKind).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/fix', + 'abc123', + {}, + undefined + ) expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix') expect(addWorktree).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -165,7 +171,9 @@ describe('OrcaRuntimeService', () => { expect(getBranchConflictKind).toHaveBeenCalledWith( TEST_REPO_PATH, 'feature/bitbucket', - 'abc123' + 'abc123', + {}, + undefined ) expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts index 17fe670a09c..3dcd2d0584c 100644 --- a/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/local-worktree-creation.spec.ts @@ -533,10 +533,14 @@ describe('OrcaRuntimeService', () => { branchNameOverride: 'feature/something' }) + // Why: an explicit branch override adopts the local branch before the conflict + // probe, so no lazy adoption callback is handed to getBranchConflictKind. expect(getBranchConflictKind).toHaveBeenCalledWith( TEST_REPO_PATH, 'feature/something', - 'origin/feature/something' + 'origin/feature/something', + {}, + undefined ) expect(addWorktree).toHaveBeenCalledWith( TEST_REPO_PATH, diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 65da9caccde..630eaebf007 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -376,7 +376,18 @@ describe('OrcaRuntimeService', () => { TEST_REPO_PATH, 'runtime-wsl', 'origin/main', - { wslDistro: 'Ubuntu' } + { wslDistro: 'Ubuntu' }, + expect.any(Function) + ) + // Why: the lazy adoption callback is only invoked when the conflict probe + // sees a local ref, so drive it here to prove adoption also routes via WSL. + const adoptLocalBranch = vi + .mocked(getBranchConflictKind) + .mock.calls.findLast((call) => call[1] === 'runtime-wsl')?.[4] + await expect(adoptLocalBranch?.()).resolves.toBe(false) + expect(gitSpy).toHaveBeenCalledWith( + ['rev-parse', '--verify', '--quiet', 'refs/heads/runtime-wsl^{commit}'], + { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } ) expect(getPRForBranchMock).toHaveBeenCalledWith( TEST_REPO_PATH, diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index cd4083acc0a..ee78396292d 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -7,6 +7,7 @@ import { paneKeyMatchSuffix } from '../pane-key-match' import { claimDispatchContextRow } from '../dispatch-row-writer' import type { DispatchCreator } from '../dispatch-depth' import type { OrchestrationDb } from '../orchestration-db' +import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' export function createDispatchContext( this: OrchestrationDb, @@ -26,10 +27,14 @@ export function createDispatchContext( const depth = this.resolveChildDispatchDepth(params.creator, params.maxDepth) const task = this.getTask(taskId) if (!task) { - throw new Error(`Task not found: ${taskId}`) + throw taskNotFoundError(`Task not found: ${taskId}`, { taskId }) } if (task.status !== 'ready') { - throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`) + throw taskNotStartableError( + this, + `Task ${taskId} is ${task.status}; only ready tasks can be dispatched`, + task + ) } // Why: lock on pane identity too, so a reminted handle can't open a second concurrent dispatch on the same pane. @@ -72,9 +77,12 @@ export function createDispatchContext( `Terminal ${assigneeHandle} already has an active dispatch (${occupied.id} for task ${occupied.task_id})` ) } - throw new Error( - `Task ${taskId} is ${current?.status ?? 'missing'}; only ready tasks can be dispatched` - ) + // Why: the atomic claim lost to a concurrent status change; report it with the same + // typed receipt as the precheck so the loser can recover instead of reading runtime_error. + const message = `Task ${taskId} is ${current?.status ?? 'missing'}; only ready tasks can be dispatched` + throw current + ? taskNotStartableError(this, message, current) + : taskNotFoundError(message, { taskId }) } this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) const dispatch = this.db diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts index e26369e7fc1..e472ea1c7e8 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts @@ -6,6 +6,7 @@ import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' import { insertStartingDispatchContextRow } from '../dispatch-row-writer' import type { DispatchCreator } from '../dispatch-depth' +import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' export function createStartingWorkerDispatch( this: OrchestrationDb, @@ -60,7 +61,7 @@ export function createStartingWorkerDispatch( } const task = this.getTask(params.taskId) if (!task) { - throw new OrchestrationError('task_not_found', `Task ${params.taskId} was not found.`) + throw taskNotFoundError(`Task ${params.taskId} was not found.`, { taskId: params.taskId }) } if (params.retryOf) { const prior = this.getDispatchContextById(params.retryOf) @@ -74,15 +75,18 @@ export function createStartingWorkerDispatch( !['failed', 'stopped', 'abandoned'].includes(priorWorker.state) || !['failed', 'blocked'].includes(task.status) ) { - throw new OrchestrationError( - 'task_not_startable', - `Task ${task.id} cannot retry from Dispatch ${params.retryOf}.` + throw taskNotStartableError( + this, + `Task ${task.id} cannot retry from Dispatch ${params.retryOf}.`, + task, + params.retryOf ) } } else if (task.status !== 'ready') { - throw new OrchestrationError( - 'task_not_startable', - `Task ${task.id} is ${task.status}; only a ready Task can start.` + throw taskNotStartableError( + this, + `Task ${task.id} is ${task.status}; only a ready Task can start.`, + task ) } diff --git a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts index 9e4a2768a1d..156d1427f01 100644 --- a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts +++ b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts @@ -168,7 +168,12 @@ describe('OrchestrationDb worker Dispatch state', () => { payloadHash: 'payload_hash' } }) - ).toThrow('was not found') + ).toThrowError( + expect.objectContaining({ + code: 'task_not_found', + message: 'Task task_missing was not found.' + }) + ) expect(d.getMutationReceipt('caller_fingerprint', 'invalid_worker_start')).toBeUndefined() }) diff --git a/src/main/runtime/orchestration/task-dispatch-refusal.ts b/src/main/runtime/orchestration/task-dispatch-refusal.ts new file mode 100644 index 00000000000..f3e026d0f73 --- /dev/null +++ b/src/main/runtime/orchestration/task-dispatch-refusal.ts @@ -0,0 +1,61 @@ +import type { OrchestrationDb } from './db' +import { OrchestrationError } from './orchestration-error' +import type { TaskRow } from './types' +import { + injectRejectedRefusal, + taskNotFoundRefusal, + taskNotStartableRefusal, + type DispatchRefusalReceipt, + type InjectRejectionReason +} from '../../../shared/orchestration-dispatch-refusal-contract' + +// Why: each site keeps the exact message it published before; only the code and data are shared. + +export function taskNotFoundError( + message: string, + detail: { taskId: string; runId?: string } +): OrchestrationError { + return toError(taskNotFoundRefusal(message, detail)) +} + +export function taskNotStartableError( + db: OrchestrationDb, + message: string, + task: TaskRow, + retryOf?: string +): OrchestrationError { + return toError( + taskNotStartableRefusal(message, { + taskId: task.id, + status: task.status, + unmetDependencies: unmetTaskDependencies(db, task), + ...(retryOf ? { retryOf } : {}) + }) + ) +} + +export function injectRejectedError( + terminal: string, + reason: InjectRejectionReason +): OrchestrationError { + return toError(injectRejectedRefusal(terminal, reason)) +} + +function toError(receipt: DispatchRefusalReceipt): OrchestrationError { + return new OrchestrationError(receipt.code, receipt.message, receipt.data) +} + +function unmetTaskDependencies(db: OrchestrationDb, task: TaskRow): string[] { + let deps: unknown + try { + deps = JSON.parse(task.deps) + } catch { + return [] + } + if (!Array.isArray(deps)) { + return [] + } + return deps.filter( + (dep): dep is string => typeof dep === 'string' && db.getTask(dep)?.status !== 'completed' + ) +} diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index 473f619e134..5989af0c283 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -1,7 +1,11 @@ import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' +import { + hardenExistingSecureFile, + isUnreadableError, + writeSecureJsonFile +} from '../../../shared/secure-file' export type RelayDeviceBinding = { relayHostId: string @@ -37,6 +41,8 @@ function isItem(value: unknown): value is RelayRevokeOutboxItem { export class RelayRevokeOutbox { private readonly path: string private items: RelayRevokeOutboxItem[] + /** Set when the outbox exists but could not be read, so `items` is not what is on disk. */ + private outboxUnreadable = false constructor(userDataPath: string) { this.path = join(userDataPath, OUTBOX_FILENAME) @@ -83,12 +89,20 @@ export class RelayRevokeOutbox { hardenExistingSecureFile(this.path) const parsed: unknown = JSON.parse(readFileSync(this.path, 'utf-8')) return Array.isArray(parsed) ? parsed.filter(isItem) : [] - } catch { + } catch (error) { + // An outbox we were denied is not an empty outbox. Saving [] over it would drop + // revocations that have not reached the relay, so a revoked device stays live. + this.outboxUnreadable = isUnreadableError(error) return [] } } private save(items: readonly RelayRevokeOutboxItem[]): void { + if (this.outboxUnreadable) { + throw new Error( + `Cannot read the relay revoke outbox at ${this.path}: the read failed. Refusing to overwrite it, which would drop pending revocations.` + ) + } writeSecureJsonFile(this.path, items) } } diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index f4b4768865b..1e4567f7f6f 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -85,6 +85,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'consumer_fenced', 'task_not_found', 'task_not_startable', + 'inject_rejected', 'dispatch_not_found', 'dispatch_run_mismatch', 'terminal_not_found', diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts new file mode 100644 index 00000000000..918724e6aaf --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { + buildInjectRejectionMessage, + injectRejectedRefusal, + taskNotFoundRefusal, + taskNotStartableRefusal +} from '../../../../shared/orchestration-dispatch-refusal-contract' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import type { RpcFailure, RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' + +const COORDINATOR_HANDLE = 'term_codes_coordinator' +const COORDINATOR_PANE = 'tab_coord:cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const WORKER_HANDLE = 'term_codes_worker' +const WORKER_PANE = 'tab_worker:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + +type Harness = { db: OrchestrationDb; runtime: OrcaRuntimeService; dispatcher: RpcDispatcher } + +const harnesses: Harness[] = [] +let requestSequence = 0 + +afterEach(() => { + for (const harness of harnesses.splice(0)) { + harness.db.close() + } + vi.restoreAllMocks() +}) + +// Why: an agent reads the receipt code to pick a recovery; every case is driven from the real +// RPC dispatcher and checked against the shared contract the CLI-side test formats. +describe('orchestration dispatch failure codes through RpcDispatcher', () => { + it('reports task_not_found for a task id that does not exist', async () => { + const harness = createHarness() + + const response = await dispatch(harness, { task: 'task_missing', to: WORKER_HANDLE }) + + expect(expectFailure(response).error).toEqual( + taskNotFoundRefusal('Task not found: task_missing', { taskId: 'task_missing' }) + ) + }) + + it('reports task_not_startable with the unmet dependencies for a pending task', async () => { + const harness = createHarness() + const parent = harness.db.createTask({ spec: 'parent' }) + const child = harness.db.createTask({ spec: 'child', deps: [parent.id] }) + + const response = await dispatch(harness, { task: child.id, to: WORKER_HANDLE }) + + expect(expectFailure(response).error).toEqual( + taskNotStartableRefusal(`Task ${child.id} is pending; only ready tasks can be dispatched`, { + taskId: child.id, + status: 'pending', + unmetDependencies: [parent.id] + }) + ) + expect(harness.db.getTask(child.id)?.status).toBe('pending') + }) + + it('reports task_not_startable with the status for a completed task', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'done' }) + harness.db.updateTaskStatus(task.id, 'completed') + + const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE }) + + expect(expectFailure(response).error).toEqual( + taskNotStartableRefusal(`Task ${task.id} is completed; only ready tasks can be dispatched`, { + taskId: task.id, + status: 'completed', + unmetDependencies: [] + }) + ) + }) + + it('reports inject_rejected when the target terminal runs no recognized agent', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'work' }) + vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false) + + const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE, inject: true }) + + expect(expectFailure(response).error).toEqual( + injectRejectedRefusal(WORKER_HANDLE, 'no_agent_detected') + ) + expect(expectFailure(response).error.message).toBe(buildInjectRejectionMessage(WORKER_HANDLE)) + expect(harness.db.getTask(task.id)?.status).toBe('ready') + expect(harness.db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('reports task_not_startable with dependency detail from worker-start', async () => { + const harness = createHarness() + const parent = harness.db.createTask({ spec: 'parent' }) + const child = harness.db.createTask({ spec: 'child', deps: [parent.id] }) + mockWorkerStartTopology(harness.runtime) + + const response = await harness.dispatcher.dispatch( + request('orchestration.workerStart', { + task: child.id, + from: COORDINATOR_HANDLE, + agent: 'claude' + }) + ) + + expect(expectFailure(response).error).toEqual( + taskNotStartableRefusal(`Task ${child.id} is pending; only a ready Task can start.`, { + taskId: child.id, + status: 'pending', + unmetDependencies: [parent.id] + }) + ) + expect(harness.db.getTask(child.id)?.status).toBe('pending') + }) + + it('reports task_not_startable with retry detail for an invalid --retry-of', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'work' }) + mockWorkerStartTopology(harness.runtime) + + const response = await harness.dispatcher.dispatch( + request('orchestration.workerStart', { + task: task.id, + from: COORDINATOR_HANDLE, + agent: 'claude', + retryOf: 'ctx_missing' + }) + ) + + expect(expectFailure(response).error).toEqual( + taskNotStartableRefusal(`Task ${task.id} cannot retry from Dispatch ctx_missing.`, { + taskId: task.id, + status: 'ready', + unmetDependencies: [], + retryOf: 'ctx_missing' + }) + ) + }) + + it('types the atomic claim loser when the task changes after the ready precheck', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'raced' }) + // Why: the pane lookup runs after the ready precheck and before the DB claim, so failing the + // task there is the interleaving a concurrent status change produces. The loser's DB refusal + // must carry the same typed receipt instead of the bare Error it used to throw. + vi.mocked(harness.runtime.getTerminalPaneKey).mockImplementation((handle) => { + if (handle === WORKER_HANDLE) { + harness.db.updateTaskStatus(task.id, 'failed', 'raced out') + return WORKER_PANE + } + return handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : null + }) + + const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE }) + + expect(expectFailure(response).error).toEqual( + taskNotStartableRefusal(`Task ${task.id} is failed; only ready tasks can be dispatched`, { + taskId: task.id, + status: 'failed', + unmetDependencies: [] + }) + ) + expect(harness.db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('keeps runtime_error for a genuinely unexpected dispatch failure', async () => { + const harness = createHarness() + const task = harness.db.createTask({ spec: 'work' }) + vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockRejectedValue( + new Error('probe exploded') + ) + + const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE, inject: true }) + + expect(expectFailure(response).error).toMatchObject({ + code: 'runtime_error', + message: 'probe exploded' + }) + }) +}) + +function expectFailure(response: RpcResponse): RpcFailure { + if (response.ok) { + throw new Error(`Expected a failure, got ${JSON.stringify(response.result)}`) + } + return response +} + +function createHarness(): Harness { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === WORKER_HANDLE ? 'pty-worker:incarnation-1' : null + ) + const runId = db.createRun({ + objective: 'Typed dispatch failures', + coordinatorHandle: COORDINATOR_HANDLE, + coordinatorPaneKey: COORDINATOR_PANE + }).id + const createTask = db.createTask.bind(db) + db.createTask = (task) => createTask({ ...task, runId: task.runId ?? runId }) + const harness = { + db, + runtime, + dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + } + harnesses.push(harness) + return harness +} + +function mockWorkerStartTopology(runtime: OrcaRuntimeService): void { + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + } as never) +} + +function dispatch(harness: Harness, params: Record): Promise { + return harness.dispatcher.dispatch( + request('orchestration.dispatch', { from: COORDINATOR_HANDLE, ...params }) + ) +} + +function request(method: string, params: Record): RpcRequest { + requestSequence += 1 + return { + id: `rpc_dispatch_error_codes_${requestSequence}`, + authToken: 'test-token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `dispatch_error_codes_${requestSequence}` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts index ae21a4e5a46..d573d944b2d 100644 --- a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts @@ -2,7 +2,11 @@ import { defineMethod, type RpcMethod } from '../core' import { OrchestrationError } from '../../orchestration/orchestration-error' import { buildDispatchPreamble } from '../../orchestration/preamble' import { resolveDispatchCreator } from './orchestration-dispatch-creator' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { + injectRejectedError, + taskNotFoundError, + taskNotStartableError +} from '../../orchestration/task-dispatch-refusal' import { resolveRunScope } from './orchestration-run-scope' import { DispatchParams, DispatchShowParams } from './orchestration-schemas' @@ -22,7 +26,7 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ const db = runtime.getOrchestrationDb() const task = db.getTask(params.task) if (!task) { - throw new Error(`Task not found: ${params.task}`) + throw taskNotFoundError(`Task not found: ${params.task}`, { taskId: params.task }) } const run = resolveRunScope(runtime, { runId: params.run, @@ -32,10 +36,10 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ callerEvidence: orchestrationCompatibilityEvidence }) if (task.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${task.id} was not found in Run ${run.id}.` - ) + throw taskNotFoundError(`Task ${task.id} was not found in Run ${run.id}.`, { + taskId: task.id, + runId: run.id + }) } // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. @@ -66,14 +70,18 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ const to = params.to if (task.status !== 'ready') { - throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + throw taskNotStartableError( + db, + `Task ${params.task} is ${task.status}; only ready tasks can be dispatched`, + task + ) } // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. if (params.inject) { const hasAgent = await runtime.isTerminalRunningAgent(to) if (!hasAgent) { - throw new Error(buildInjectRejectionMessage(to)) + throw injectRejectedError(to, 'no_agent_detected') } } diff --git a/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.test.ts b/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.test.ts deleted file mode 100644 index a7ae03b00a8..00000000000 --- a/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' -import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' -import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' - -describe('buildInjectRejectionMessage', () => { - const message = buildInjectRejectionMessage('term_a') - - it('keeps the substring callers and scripts match on', () => { - expect(message).toContain('Cannot dispatch --inject to terminal term_a') - expect(message).toContain('no recognized agent detected') - }) - - it('names every agent Orca recognizes, including agy', () => { - expect(message).toMatch(/\bagy\b/) - for (const config of Object.values(TUI_AGENT_CONFIG)) { - expect(message).toContain(config.expectedProcess) - } - }) - - it('lists only names detection actually resolves, deduped and sorted', () => { - const listed = (/\(([^)]+)\)/.exec(message)?.[1] ?? '').split(', ') - - expect(listed.length).toBeGreaterThan(0) - expect(new Set(listed).size).toBe(listed.length) - expect([...listed].sort()).toEqual(listed) - for (const name of listed) { - expect(recognizeAgentProcess(name)).not.toBeNull() - } - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.ts b/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.ts deleted file mode 100644 index 33d622ca80e..00000000000 --- a/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' - -// Why: the old five-name example read as an allowlist (#15125); derive from the field detection keys on so it cannot drift. -// Not filtered by `disabledTuiAgents` — that gates Orca's launchers, not detection, so a hand-started disabled agent still injects. -const RECOGNIZED_AGENT_PROCESS_NAMES = [ - ...new Set(Object.values(TUI_AGENT_CONFIG).map((config) => config.expectedProcess)) -].sort() - -export function buildInjectRejectionMessage(terminal: string): string { - return ( - `Cannot dispatch --inject to terminal ${terminal}: no recognized agent detected. ` + - `Orca detects these agent CLIs (${RECOGNIZED_AGENT_PROCESS_NAMES.join(', ')}). ` + - 'Start one in the terminal and let it finish launching, ' + - 'or dispatch without --inject and send the prompt manually.' - ) -} diff --git a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts index a216b4c7a4d..cd6d79c9fd5 100644 --- a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts @@ -3,7 +3,7 @@ import type { RpcContext } from '../core' import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' import type { OrchestrationDb } from '../../orchestration/db' import type { OrcaRuntimeService } from '../../orca-runtime' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { buildInjectRejectionMessage } from '../../../../shared/orchestration-dispatch-refusal-contract' import { createRootDispatch } from '../../orchestration/db/root-dispatch-test-fixture' describe('orchestration RPC methods', () => { diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 61271525939..632b34cc1b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -21,6 +21,7 @@ import { import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { taskNotFoundError } from '../../orchestration/task-dispatch-refusal' import { resolveOrchestrationCaller } from './orchestration-run-scope' import { isWorkerStartTimeoutWithinTimerLimit, @@ -58,10 +59,10 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ } const task = db.getTask(params.task) if (!task || task.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${params.task} was not found in Run ${run.id}.` - ) + throw taskNotFoundError(`Task ${params.task} was not found in Run ${run.id}.`, { + taskId: params.task, + runId: run.id + }) } if (params.on) { diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 450375e48f8..04293d3cca4 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -116,6 +116,34 @@ describe('ssh RPC methods', () => { } ] listRegisteredSshTargetsMock.mockReturnValueOnce(targets) + getRegisteredSshStateMock.mockReturnValueOnce({ status: 'connected', remotePlatform: 'win32' }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries')) + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [ + { + id: 'ssh-1', + label: 'Dev box', + connected: true, + connectionStatus: 'connected', + remotePlatform: 'win32' + } + ] + } + }) + expect(JSON.stringify(response)).not.toContain('dev.internal') + expect(JSON.stringify(response)).not.toContain('/secret/key') + expect(JSON.stringify(response)).not.toContain('bastion') + }) + + it('does not invent a platform before the SSH host has been detected', async () => { + listRegisteredSshTargetsMock.mockReturnValueOnce([{ id: 'ssh-1', label: 'Dev box' }]) + getRegisteredSshStateMock.mockReturnValueOnce(undefined) const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) @@ -125,9 +153,23 @@ describe('ssh RPC methods', () => { ok: true, result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] } }) - expect(JSON.stringify(response)).not.toContain('dev.internal') - expect(JSON.stringify(response)).not.toContain('/secret/key') - expect(JSON.stringify(response)).not.toContain('bastion') + expect(JSON.stringify(response)).not.toContain('remotePlatform') + }) + + it('reports disconnected lifecycle states without calling them connected', async () => { + listRegisteredSshTargetsMock.mockReturnValueOnce([{ id: 'ssh-1', label: 'Dev box' }]) + getRegisteredSshStateMock.mockReturnValueOnce({ status: 'reconnecting' }) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargetSummaries')) + + expect(response).toMatchObject({ + ok: true, + result: { + targets: [{ id: 'ssh-1', connected: false, connectionStatus: 'reconnecting' }] + } + }) }) it('redacts the legacy target response for older clients', async () => { diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 2e0a4e4f4ab..e6cb6b47b50 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -15,11 +15,18 @@ const SshTarget = z.object({ // Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone. function listRegisteredSshTargetSummaries(): SshTargetSummary[] { - return listRegisteredSshTargets().map(({ id, label, generation }) => ({ - id, - label, - ...(generation === undefined ? {} : { generation }) - })) + return listRegisteredSshTargets().map(({ id, label, generation }) => { + const state = getRegisteredSshState(id) + const remotePlatform = state?.remotePlatform + return { + id, + label, + ...(generation === undefined ? {} : { generation }), + connected: state?.status === 'connected', + ...(state?.status === undefined ? {} : { connectionStatus: state.status }), + ...(remotePlatform === undefined ? {} : { remotePlatform }) + } + }) } export const SSH_METHODS: RpcMethod[] = [ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index e15544b2d24..d918614ed47 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -37,9 +37,10 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo return host } -/** Attach is the only way a session comes into being, so it is the only call - * that builds the host. Every other method addresses a session that must - * already be attached, and correctly reports absent when none is. */ +/** Builds the host for the calls that address a session by durable record rather than by live + * state: attach, which is the only way a session comes into being, plus hold and reveal, which + * each reach for a record on disk this process may not have opened yet. Every other method + * addresses a session that must already be attached, and correctly reports absent when none is. */ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts new file mode 100644 index 00000000000..5f2ab0e8cac --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -0,0 +1,57 @@ +// `agentSession.reveal` — republishing the tab for a chat that still exists on disk. +// +// A surface can hold nothing but a session id: an Agent Session History row names a chat this +// process may never have published, because `close` keeps the record and the journal but not the +// tab, and a client drops every unpublished `agent-session` tab on each session-tabs sync. Without +// this the row is unreachable, and the client's "retry in a moment" is advice that never comes true. +// +// Distinct from `agentSession.ensure`, which attaches a location the CLIENT supplies. Here the host +// reads its own record and answers with that record's workspace and provider, so a client knowing +// only a session id cannot aim the publication somewhere else. + +import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' +import { defineMethod, type RpcAnyMethod } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredCapability, + requireStructuredHost +} from './structured-agent-session-gate' +import { OptionsParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.reveal', + params: OptionsParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + await ensureStructuredHostInstalled(ctx) + let revealed: StructuredAgentSessionReveal + try { + revealed = await requireStructuredHost(ctx).revealSession(params.sessionId) + } catch (error) { + // The host raises its refusal as the code itself; anything else is a genuine fault and + // must not be laundered into a tidy "no such chat". + const code = error instanceof Error ? error.message : '' + if (!isAgentSessionWireRefusalCode(code)) { + throw error + } + return refuseAgentSessionMutation({ + code, + message: + code === 'structured_agent_session_unsupported' + ? 'This host cannot open that chat.' + : 'This chat is no longer on this host.' + }) + } + await ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: revealed.workspaceId, + sessionId: revealed.sessionId, + agent: revealed.agent, + activate: true + }) + return { ok: true as const, ...revealed } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index da66923c731..58dece2256c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -152,9 +152,13 @@ export const CancelParams = z .object({ envelope: MutationEnvelope, turnId: Identifier('Invalid turn id'), - scope: z.literal('background-tasks').optional() + scope: z.literal('background-tasks').optional(), + taskId: Identifier('Invalid task id').optional() }) .strict() + .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { + message: 'A task id requires background-task scope' + }) export const RespondParams = z .object({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts new file mode 100644 index 00000000000..8637089c254 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -0,0 +1,62 @@ +// `agentSession.subscribeStatus` — every structured session's projected status on one stream. +// +// Session lists read turn state from here instead of replaying transcripts: one stream per client +// covers every session, and unlike a transcript subscription it retains none of them. + +import { defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' + +/** Ties a stream to both ends that can close it — the runtime's subscription registry and the + * transport abort — so either one runs `onClose` exactly once. */ +export function bindStructuredAgentSessionStream( + ctx: RpcContext, + subscriptionId: string, + onClose: () => void +): { isClosed: () => boolean } { + let closed = false + let releaseTransportSubscription = (): void => {} + const onTransportAbort = (): void => releaseTransportSubscription() + const cleanup = (): void => { + closed = true + ctx.signal?.removeEventListener('abort', onTransportAbort) + onClose() + } + let registration: { releaseIfCurrent: () => void } + if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') { + registration = ctx.runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + cleanup, + ctx.connectionId + ) + } else { + ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId) + registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) } + } + releaseTransportSubscription = registration.releaseIfCurrent + ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) + if (ctx.signal?.aborted) { + onTransportAbort() + } + return { isClosed: () => closed } +} + +export const STRUCTURED_AGENT_SESSION_STATUS_METHODS: RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'agentSession.subscribeStatus', + params: null, + handler: async (_params, ctx, emit) => { + const host = requireHost(ctx) + const subscriptionId = structuredAgentSessionStatusSubscriptionId(ctx) + let dispose = (): void => {} + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => dispose()) + if (stream.isClosed()) { + return + } + dispose = host.subscribeStatus({ id: subscriptionId, emit }) + if (stream.isClosed()) { + dispose() + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts b/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts new file mode 100644 index 00000000000..f3d00232359 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts @@ -0,0 +1,29 @@ +// Subscription ids for the streaming `agentSession.*` methods. +// +// Shared control multiplexes several streams over one socket, so the frame id keeps one +// subscriber from evicting another. It is appended only when present: collapsing a missing +// frame id to a constant is the collision the rule exists to prevent. + +import type { RpcContext } from '../core' + +const SUBSCRIPTION_PREFIX = 'agentSession' + +function withFrameId(ctx: RpcContext, base: string): string { + return ctx.requestId ? `${base}:${ctx.requestId}` : base +} + +/** The id a session's streams share before the frame id. `unsubscribe` addresses this + * directly and sweeps `${base}:` to reach every frame under it. */ +export function structuredAgentSessionSubscriptionBase(ctx: RpcContext, sessionId: string): string { + return `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` +} + +/** One session's transcript stream. */ +export function structuredAgentSessionSubscriptionId(ctx: RpcContext, sessionId: string): string { + return withFrameId(ctx, structuredAgentSessionSubscriptionBase(ctx, sessionId)) +} + +/** The status feed, which is per connection rather than per session. */ +export function structuredAgentSessionStatusSubscriptionId(ctx: RpcContext): string { + return withFrameId(ctx, `${SUBSCRIPTION_PREFIX}.status:${ctx.connectionId ?? 'local'}`) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index e4888e4a9df..4af7f165d0f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -2,12 +2,19 @@ // accepts once they can. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusSubscriber +} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -64,6 +71,44 @@ function request(method: string, params: unknown): RpcRequest { let hostCalls: Record> let runtimeCalls: Record> +const STATUS_SESSION = 'session-status' +const STATUS_ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] } + }, + { + itemId: 'turn-1', + sequence: 2, + revision: 1, + observedAt: 2, + body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } } + } +] + +/** One indexed session over a journal that reads back fixed items; the projection is real. */ +function statusFeed(): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + STATUS_SESSION, + { + journal: { + isReadOnly: false, + snapshot: () => ({ items: STATUS_ITEMS }) + } as unknown as AgentSessionJournal, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } + } + ] + ]), + getRecord: () => null, + now: () => 1_000 + }) +} + function hostStub(): StructuredAgentSessionHost { hostCalls = { attach: vi.fn(async () => ({ @@ -96,6 +141,12 @@ function hostStub(): StructuredAgentSessionHost { send: vi.fn(async () => ({ ok: true, replayed: false })), cancel: vi.fn(async () => ({ ok: true, replayed: false })), close: vi.fn(async () => undefined), + revealSession: vi.fn(async () => ({ + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex' as const, + readable: true + })), setSessionTabVisibility: vi.fn(async () => undefined), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), @@ -122,6 +173,11 @@ function hostStub(): StructuredAgentSessionHost { })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), + // A real feed, so the snapshot this method hands back is a genuine projection rather + // than a shape the stub restated. + subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => + statusFeed().subscribe(subscriber) + ), unsubscribe: vi.fn() } return hostCalls as unknown as StructuredAgentSessionHost @@ -204,6 +260,92 @@ afterEach(() => { setStructuredAgentSessionHost(null) }) +describe('agentSession.reveal', () => { + it.each(['codex', 'claude'] as const)('republishes a persisted %s chat tab', async (agent) => { + hostCalls.revealSession.mockResolvedValueOnce({ + sessionId: SESSION, + workspaceId: 'workspace-1', + agent, + readable: true + }) + + const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(hostCalls.revealSession).toHaveBeenCalledWith(SESSION) + expect(response).toMatchObject({ ok: true, result: { ok: true, agent } }) + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ agent, activate: true }) + ) + }) + + it('publishes the workspace the host reported, not one the client could assert', async () => { + // The client sends only a session id, so a stale or forged one cannot aim the publish at + // another workspace. + hostCalls.revealSession.mockResolvedValueOnce({ + sessionId: SESSION, + workspaceId: 'workspace-from-record', + agent: 'claude', + readable: true + }) + + await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith({ + workspaceId: 'workspace-from-record', + sessionId: SESSION, + agent: 'claude', + activate: true + }) + }) + + it('publishes the tab even when the journal could not be read', async () => { + // A pre-SQLite chat restores to nothing, but attach still recovers it, so the tab is worth + // publishing and the pane's hold finishes the job. Refusing here would strand it forever. + hostCalls.revealSession.mockResolvedValueOnce({ + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + readable: false + }) + + const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true, result: { ok: true, readable: false } }) + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce() + }) + + it('refuses rather than throws when the host holds no such record', async () => { + hostCalls.revealSession.mockRejectedValueOnce(new Error('agent_session_identity_required')) + + const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_identity_required' } } + }) + expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + }) + + it('does not launder an unrelated fault into a refusal', async () => { + hostCalls.revealSession.mockRejectedValueOnce(new Error('EACCES: journal directory')) + + const response = await call('agentSession.reveal', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: false }) + }) + + it('is refused for a client that cannot read structured sessions', async () => { + const response = await call( + 'agentSession.reveal', + { sessionId: SESSION }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + + expect(response).toMatchObject({ ok: false }) + expect(hostCalls.revealSession).not.toHaveBeenCalled() + }) +}) + describe('capability gating', () => { it('clears durable tab visibility when closing through the agent-session RPC', async () => { const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) @@ -228,6 +370,7 @@ describe('capability gating', () => { it('advertises the capability without bumping the protocol version', () => { expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY) // Additive methods do not break an old client; bumping would strand every // paired device that has not updated. expect(RUNTIME_PROTOCOL_VERSION).toBe(3) @@ -240,7 +383,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(19) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -480,6 +623,20 @@ describe('method routing', () => { ]) }) + it('routes an optional background task id through cancellation', async () => { + const params = { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks' as const, + taskId: 'task-2' + } + + const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) + }) + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), @@ -510,6 +667,21 @@ describe('parameter validation', () => { }) }) + it('rejects invalid or unscoped background task ids', async () => { + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: ' task-2' + }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'turn-1', + taskId: 'task-2' + }) + expect(hostCalls.cancel).not.toHaveBeenCalled() + }) + it('refuses to let a client author anything but a user turn', async () => { await rejects( 'agentSession.send', @@ -583,3 +755,31 @@ describe('parameter validation', () => { expect(response).toMatchObject({ ok: true }) }) }) + +describe('agentSession.subscribeStatus', () => { + it('is invisible to a client without the structured capability', async () => { + const reply = await call('agentSession.subscribeStatus', null, { clientKind: 'runtime' }) + expect(reply.ok).toBe(false) + expect(hostCalls.subscribeStatus).not.toHaveBeenCalled() + }) + + it('opens the host status feed with a projected snapshot as its first reply', async () => { + const reply = await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT) + expect(reply).toMatchObject({ + ok: true, + result: { + type: 'snapshot', + sessions: [ + { + sessionId: STATUS_SESSION, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'write a poem' + } + ] + } + }) + expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b69ff6fd628..d524c2185df 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -20,7 +20,16 @@ import { } from './structured-agent-session-gate' import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' +import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' +import { + bindStructuredAgentSessionStream, + STRUCTURED_AGENT_SESSION_STATUS_METHODS +} from './structured-agent-session-status-stream' +import { + structuredAgentSessionSubscriptionBase as subscriptionBaseFor, + structuredAgentSessionSubscriptionId as subscriptionIdFor +} from './structured-agent-session-subscription-id' import { AttachParams, CancelParams, @@ -37,15 +46,6 @@ import { UnsubscribeParams } from './structured-agent-session-schemas' -const SUBSCRIPTION_PREFIX = 'agentSession' - -function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { - const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` - // Shared control multiplexes several streams over one socket; the frame id - // keeps one subscriber from evicting another on the same session. - return ctx.requestId ? `${base}:${ctx.requestId}` : base -} - /** * The attach-shaped entries take the location from the client instead of resolving it from a * worktree, so they never reach the worktree-resolving create-support check. Ask the executing @@ -245,33 +245,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ // Retain-only: reading history must never be what starts a provider process. Current clients // explicitly hold every open surface before subscribing. const streamHolder = `subscription:${subscriptionId}` - let closed = false let dispose = (): void => {} - let releaseTransportSubscription = (): void => {} - const onTransportAbort = (): void => releaseTransportSubscription() - const cleanup = () => { - closed = true - ctx.signal?.removeEventListener('abort', onTransportAbort) + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => { dispose() host.release(params.sessionId, streamHolder) - } - let registration: { releaseIfCurrent: () => void } - if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') { - registration = ctx.runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - cleanup, - ctx.connectionId - ) - } else { - ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId) - registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) } - } - releaseTransportSubscription = registration.releaseIfCurrent - ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) - if (ctx.signal?.aborted) { - onTransportAbort() - } - if (closed) { + }) + if (stream.isClosed()) { return } // The host emits the opening snapshot (or the missed batch) synchronously @@ -282,7 +261,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ emit, ...(params.cursor ? { cursor: params.cursor } : {}) }) - if (closed) { + if (stream.isClosed()) { dispose() } else { // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a @@ -300,8 +279,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: UnsubscribeParams, handler: async (params, ctx) => { requireHost(ctx) - const connection = ctx.connectionId ?? 'local' - const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}` + const base = subscriptionBaseFor(ctx, params.sessionId) if (params.subscriptionId) { ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) return { unsubscribed: true } @@ -311,5 +289,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ return { unsubscribed: true } } }), - ...STRUCTURED_AGENT_SESSION_HOLD_METHODS + ...STRUCTURED_AGENT_SESSION_HOLD_METHODS, + ...STRUCTURED_AGENT_SESSION_REVEAL_METHODS, + ...STRUCTURED_AGENT_SESSION_STATUS_METHODS ] diff --git a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts index 35225f72f9c..b5589aa7724 100644 --- a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts +++ b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts @@ -1,26 +1,71 @@ // Why: the E2EE auth handshake used to persist `lastSeenAt` inline, and on Windows every secure-file -// write blocks the main thread on two synchronous PowerShell ACL spawns (~1-1.5s cold each). These tests +// write blocks the main thread on synchronous icacls ACL spawns (~1-1.5s cold each). These tests // pin the spawn count on the auth critical path, not wall-clock, so they are deterministic under load. -import { execFile, execFileSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebSocket } from 'ws' +import type { ProcessResult, ProcessSpec } from '../../../shared/child-process/run-process' +import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' import { DEVICE_REGISTRY_FILENAME } from '../mobile-pairing-files' import { DeviceRegistry, type DeviceEntry } from '../device-registry' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './e2ee-crypto' import { MobileSocketWiring, type MobileSocketTransport } from './mobile-socket-wiring' -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), - execFile: vi.fn() +// Why this module and not `node:child_process`: hardening reaches the OS only through +// runProcess/runProcessSync, and a hand-written child_process factory silently omitted the one +// function they call — so every spawn threw, hardening no-opped, and the test double hid it. +vi.mock('../../../shared/child-process/run-process', () => ({ + runProcess: vi.fn(), + runProcessSync: vi.fn() })) -// Why: stands in for the PowerShell cold start; long enough that a gated response would be obvious, +// Why: stands in for the icacls cold start; long enough that a gated response would be obvious, // short enough that the suite stays fast. Assertions use the recorded ordering, never this number. const INJECTED_SPAWN_LATENCY_MS = 5 -const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' +const USER_SID = 'S-1-5-21-1000' +const OK: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } +/** + * One secure write hardens two paths: the staged temp file, fresh and still on the inherited DACL, + * costs the full verify/reset/grant/verify pass; the published file, whose protected DACL came + * along with the rename, costs only its verify. + */ +const BLOCKING_SPAWNS_PER_WRITE = 5 + +/** Paths the fake icacls has granted a protected DACL, keyed to the ACE flags the grant used. */ +const hardenedByFake = new Map() + +/** + * Stands in for icacls. `/save` really writes a UTF-16LE SDDL file, because the code under test + * reads that file back off disk to decide whether a rewrite is needed at all — which is what makes + * the spawn count per write a property of the real ACL path rather than of this double. + */ +function fakeIcacls(spec: ProcessSpec): ProcessResult { + const args = spec.args ?? [] + const path = args[0] ?? '' + const grantIndex = args.indexOf('/grant:r') + if (grantIndex !== -1) { + hardenedByFake.set(path, args[grantIndex + 1]!.includes('(OI)(CI)') ? 'OICI' : '') + return OK + } + const saveIndex = args.indexOf('/save') + if (saveIndex === -1) { + return OK // /reset + } + writeFileSync(args[saveIndex + 1]!, fakeSddl(path), 'utf16le') + return OK +} + +function fakeSddl(path: string): string { + const aceFlags = hardenedByFake.get(path) + if (aceFlags === undefined) { + // Never hardened: the inherited DACL a fresh file carries, so the first verify must fail. + return `name\r\nD:(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;${USER_SID})\r\n` + } + const ace = (sid: string): string => `(A;${aceFlags};FA;;;${sid})` + return `name\r\nD:PAI${ace('BA')}${ace('SY')}${ace(USER_SID)}\r\n` +} type TimelineEntry = 'acl-spawn' | 'e2ee_ready' | 'e2ee_authenticated' | 'other-frame' @@ -62,24 +107,23 @@ describe('mobile auth critical path', () => { process.env.SystemRoot = 'C:\\Windows' Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) userDataPath = mkdtempSync(join(tmpdir(), 'orca-auth-acl-')) - vi.mocked(execFileSync).mockReset() - vi.mocked(execFile).mockReset() - vi.mocked(execFileSync).mockImplementation((file) => { - if (String(file).endsWith('whoami.exe')) { - return '"USER","S-1-5-21-1000"' + hardenedByFake.clear() + vi.mocked(runProcessSync).mockReset() + vi.mocked(runProcess).mockReset() + vi.mocked(runProcessSync).mockImplementation((spec) => { + // Matched by suffix, not by the whole path: `windowsSystem32Binary` joins with the host + // separator, so the literal only matches when the tests happen to run on Windows. + if (spec.program.endsWith('whoami.exe')) { + return { ...OK, stdout: `"USER","${USER_SID}"` } } timeline.push('acl-spawn') // Why: the real spawn blocks the main thread, so the fake must too — and via Atomics, not a // Date.now() spin, which would never terminate under fake timers. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, INJECTED_SPAWN_LATENCY_MS) - return '' - }) - vi.mocked(execFile).mockImplementation((_file, _args, _options, callback) => { - if (typeof callback === 'function') { - callback(null, '', '') - } - return {} as ReturnType + return fakeIcacls(spec) }) + // The directory harden stays on the async lane, so it never lands on the timeline. + vi.mocked(runProcess).mockImplementation((spec) => Promise.resolve(fakeIcacls(spec))) }) afterEach(() => { @@ -157,8 +201,11 @@ describe('mobile auth critical path', () => { registry.flushPendingLastSeen() // Hardening is deferred, never dropped: tmp file + published file, exactly as the inline path did. - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2) - expect(vi.mocked(execFileSync).mock.lastCall?.[0]).toBe(POWERSHELL) + expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( + BLOCKING_SPAWNS_PER_WRITE + ) + // The blocking work is the ACL tool itself, not some other spawn on the same lane. + expect(vi.mocked(runProcessSync).mock.lastCall?.[0].program).toMatch(/icacls\.exe$/) expect(readPersistedDevices()[0]?.lastSeenAt).toBe( registry.getDevice(device.deviceId)?.lastSeenAt ) @@ -172,7 +219,11 @@ describe('mobile auth critical path', () => { authenticate(registry, device) // Why: rotatePendingDevice drops entries disk says were never scanned, so this write stays inline. - expect(timeline).toEqual(['e2ee_ready', 'acl-spawn', 'acl-spawn', 'e2ee_authenticated']) + expect(timeline).toEqual([ + 'e2ee_ready', + ...Array(BLOCKING_SPAWNS_PER_WRITE).fill('acl-spawn'), + 'e2ee_authenticated' + ]) expect(readPersistedDevices()[0]?.lastSeenAt).toBeGreaterThan(0) }) @@ -189,7 +240,9 @@ describe('mobile auth critical path', () => { expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(0) vi.advanceTimersByTime(250) - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2) + expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( + BLOCKING_SPAWNS_PER_WRITE + ) }) it('cancels the deferred rewrite when another registry save persists the timestamp', () => { @@ -201,9 +254,13 @@ describe('mobile auth critical path', () => { registry.updateLastSeenDeferred(device.deviceId) registry.addDevice('Other client', 'runtime') - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2) + expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( + BLOCKING_SPAWNS_PER_WRITE + ) vi.advanceTimersByTime(250) - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(2) + expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( + BLOCKING_SPAWNS_PER_WRITE + ) }) }) diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index 6e0ec4896c8..e7f3172f753 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split class members. +import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' @@ -58,7 +59,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC } const acc = createAccumulator() - let stdoutBuffer = '' + const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -84,6 +85,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC let killTimeout: ReturnType | null = null const cleanupListeners = (): void => { + lines.clear() if (killTimeout) { clearTimeout(killTimeout) killTimeout = null @@ -123,12 +125,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC nextChild.stdout!.setEncoding('utf-8') const onStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } + lines.push(chunk, processLine) } const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. @@ -152,8 +149,9 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC resolveWithoutRipgrep() return } - if (stdoutBuffer) { - processLine(stdoutBuffer) + const tail = lines.finish() + if (tail !== null) { + processLine(tail) } resolveOnce() } diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index 6c454666148..9de955c5cd1 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -110,23 +110,31 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: { args.username, args.localWorktreeGitOptions ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - args.repo.path, - branchName, - args.baseBranch, - ...args.localWorktreeGitOptionArgs - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - selectedExistingLocalBranchName = branchName + const tryExistingBranch = async (): Promise => { + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + args.repo.path, + branchName, + args.baseBranch, + ...args.localWorktreeGitOptionArgs + ) + return checkoutExistingBranch } + const preferExistingBranch = Boolean( + args.request.branchNameOverride || selectedExistingLocalBranchName + ) + checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch()) branchConflictKind = checkoutExistingBranch ? null : await getBranchConflictKind( args.repo.path, branchName, args.baseBranch, - ...args.localWorktreeGitOptionArgs + args.localWorktreeGitOptions, + preferExistingBranch ? undefined : tryExistingBranch ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + selectedExistingLocalBranchName = branchName + } const allowedPushTargetRemoteConflict = branchConflictKind && isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args.request) diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts index 495a7933842..0be5ecad50e 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.test.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -6,6 +6,37 @@ import { } from './runtime-mobile-file-path-search' describe('rankRuntimeMobileFilePaths', () => { + it('preserves basename matching, ordering and total counts for unusual paths', () => { + const paths = [ + '', + '/', + 'a/', + 'a//b.ts', + 'b.ts', + 'B.TS', + 'a\\b.ts', + '界/😀.ts', + '.hidden', + 'a/./b.ts' + ] + for (const query of ['', ' ', 'b', '.ts', '😀', '/', 'a\\', 'missing']) { + for (const limit of [0, 1, 3, 100]) { + const q = query.trim().toLowerCase() + const prefix = paths.filter((path) => { + const lower = path.toLowerCase() + return lower.startsWith(q) || (lower.split('/').pop() ?? lower).startsWith(q) + }) + const other = paths.filter( + (path) => !prefix.includes(path) && path.toLowerCase().includes(q) + ) + expect(rankRuntimeMobileFilePaths(paths, query, limit)).toEqual({ + paths: [...prefix, ...other].slice(0, limit), + totalCount: prefix.length + other.length + }) + } + } + }) + it('ranks path and basename prefixes before substrings and caps output', () => { expect( rankRuntimeMobileFilePaths( diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts index 13213d6dfcf..1455028c798 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -76,7 +76,7 @@ export function rankRuntimeMobileFilePaths( let totalCount = 0 for (const path of paths) { const lower = path.toLowerCase() - const basename = lower.split('/').pop() ?? lower + const basename = lower.slice(lower.lastIndexOf('/') + 1) if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { totalCount++ if (prefix.length < limit) { diff --git a/src/main/runtime/runtime-remote-fetch-controller.ts b/src/main/runtime/runtime-remote-fetch-controller.ts index f40f25f6c82..dbcc240525b 100644 --- a/src/main/runtime/runtime-remote-fetch-controller.ts +++ b/src/main/runtime/runtime-remote-fetch-controller.ts @@ -231,7 +231,7 @@ export class RuntimeRemoteFetchController { ? baseBranch.slice(remoteRefPrefix.length) : baseBranch // A remote-tracking base needs both a configured remote and a branch component. - if (!shortBaseBranch.includes('/')) { + if (shortBaseBranch.indexOf('/') <= 0 || shortBaseBranch.endsWith('/')) { return null } let remotes: string[] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 767ca885234..05666add0bc 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -205,6 +205,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.createSupport', 'agentSession.create', 'agentSession.ensure', + 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', 'agentSession.close', diff --git a/src/main/runtime/runtime-search-line-fragments.test.ts b/src/main/runtime/runtime-search-line-fragments.test.ts new file mode 100644 index 00000000000..17c873efdd8 --- /dev/null +++ b/src/main/runtime/runtime-search-line-fragments.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { + checkRgAvailableMock, + resolveAuthorizedPathMock, + wslAwareSpawnMock +} from './orca-runtime-files-mock-registry' +import { + createRuntimeFileCommands, + useRuntimeFileCommandsLifecycle +} from './orca-runtime-files-test-harness' + +vi.mock('fs', async () => (await import('./orca-runtime-files-mock-registry')).fsModuleMock()) +vi.mock('fs/promises', async () => + (await import('./orca-runtime-files-mock-registry')).fsPromisesModuleMock() +) +vi.mock( + './file-watcher-host', + async () => (await import('./orca-runtime-files-mock-registry')).fileWatcherHostMock +) +vi.mock('../ipc/filesystem-auth', async () => + (await import('./orca-runtime-files-mock-registry')).filesystemAuthModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./orca-runtime-files-mock-registry')).gitRunnerModuleMock() +) +vi.mock( + '../ipc/rg-availability', + async () => (await import('./orca-runtime-files-mock-registry')).rgAvailabilityMock +) +vi.mock( + '../ipc/local-worktree-runtime-options', + async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock +) +vi.mock( + '../ipc/filesystem-search-git', + async () => (await import('./orca-runtime-files-mock-registry')).filesystemSearchGitMock +) +vi.mock( + '../providers/ssh-filesystem-dispatch', + async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock +) + +type MockRuntimeSearchChild = EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter + kill: ReturnType +} + +function createRuntimeSearchChild(): MockRuntimeSearchChild { + const child = new EventEmitter() as MockRuntimeSearchChild + child.stdout = new EventEmitter() as MockRuntimeSearchChild['stdout'] + child.stdout.setEncoding = vi.fn() + child.stderr = new EventEmitter() + child.kill = vi.fn() + return child +} + +async function flushRuntimeSearchMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + +describe('RuntimeFileCommands', () => { + useRuntimeFileCommandsLifecycle() + + it('assembles a fragmented runtime search record without rescanning the carry', async () => { + const { commands } = createRuntimeFileCommands({ + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + executionHostId: 'local' + })) + }) + const child = createRuntimeSearchChild() + resolveAuthorizedPathMock.mockResolvedValue('/repo') + checkRgAvailableMock.mockResolvedValue(true) + wslAwareSpawnMock.mockReturnValue(child) + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + const line = JSON.stringify({ + type: 'match', + data: { + path: { text: '/repo/file.ts' }, + line_number: 1, + lines: { text: `needle🐋${'x'.repeat(128 * 1024)}` }, + submatches: [{ start: 0, end: 6 }] + } + }) + const originalSplit = String.prototype.split + let scanned = 0 + const spy = vi.spyOn(String.prototype, 'split').mockImplementation(function ( + this: string, + separator: unknown, + limit?: number + ) { + if (separator === '\n') { + scanned += this.length + } + return Reflect.apply(originalSplit, this, [separator, limit]) + }) + try { + for (let offset = 0; offset < line.length; offset += 1024) { + child.stdout.emit('data', line.slice(offset, offset + 1024)) + } + child.emit('close', 0, null) + } finally { + spy.mockRestore() + } + const result = await resultPromise + expect(result.totalMatches).toBe(1) + expect(result.files[0].filePath).toBe('/repo/file.ts') + expect(result.files[0].matches[0].lineContent).toContain('needle🐋') + expect(scanned).toBeLessThanOrEqual(line.length * 2) + expect(child.stdout.listenerCount('data')).toBe(0) + }) +}) diff --git a/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts b/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts new file mode 100644 index 00000000000..d92deccfa9a --- /dev/null +++ b/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts @@ -0,0 +1,262 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { windowsSystem32Binary } from '../../shared/child-process/windows-system-binary' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +/** + * What a *successful* harden does to a reader that cannot read. + * + * Path hardening writes a protected DACL granting only the SIDs the running process holds. Where + * the data root came from somewhere else — a relocated `ORCA_USER_DATA_PATH`, a share, a roaming + * profile, a backup restored under a recreated local account, or a harden whose `/reset` landed + * and whose `/grant` did not — the file ends up granting a SID this process does not have. It then + * reads as `EPERM` while its *directory* stays writable, because file hardening is synchronous on + * the write path and directory hardening is fire-and-forget. + * + * Every store below used to treat any read failure as "malformed — regenerate", and the + * regeneration succeeds: `renameSync` over an unreadable file needs `FILE_DELETE_CHILD` on the + * parent, not `DELETE` on the file. So the healing path destroyed the thing it could not read. + * Before hardening actually applied, this failed open — the file was simply readable. + * + * These assert the file still holds its original bytes afterwards. Runs only on win32, where a + * DACL is the mechanism; skipped elsewhere. + */ + +/** Whether a DACL that omits this token actually denies it a read. */ +function readDenied(filePath: string): boolean { + try { + readFileSync(filePath, 'utf8') + return false + } catch (error) { + return /^(?:EPERM|EACCES)$/.test((error as NodeJS.ErrnoException).code ?? '') + } +} + +/** + * An elevated token logged in as the built-in Administrator reads straight through a DACL that + * grants it nothing, so on such a host every assertion here would pass while proving nothing. + * Probe once and skip rather than assert vacuously -- the same trade the ACL suite makes for its + * unelevated-only case. `isUnreadableError` has its own unit tests on every platform; this suite + * carries the stores' refusal wherever a denial is actually reproducible. + */ +function canDenyReads(): boolean { + if (process.platform !== 'win32') { + return false + } + const probeRoot = mkdtempSync(join(tmpdir(), 'orca-deny-probe-')) + const probe = join(probeRoot, 'probe.json') + try { + writeFileSync(probe, '{}') + icacls(probe, '/inheritance:r', '/q') + icacls(probe, '/grant:r', `*${FOREIGN_SID}:(F)`, '/q') + return readDenied(probe) + } finally { + icacls(probe, '/reset', '/q') + icacls(probeRoot, '/reset', '/t', '/q') + removeTreeSync(probeRoot) + } +} + +/** + * BUILTIN\Guests: a real, always-resolvable group that no interactive token is a member of. + * `S-1-5-32-544` looks foreign only until the suite meets a host that is elevated AND logged + * in as the built-in Administrator -- a CI runner -- where it grants the reader full control + * and every assertion below goes vacuous. An unresolvable SID is not an option: icacls + * rejects one with ERROR_NONE_MAPPED (1332). + */ +const FOREIGN_SID = 'S-1-5-32-546' + +function icacls(...args: string[]): number | null { + return runProcessSync({ + program: windowsSystem32Binary('icacls.exe'), + args, + timeoutMs: 10_000 + }).code +} + +/** The on-disk state a successful harden leaves for a SID this process does not hold. */ +function makeUnreadable(filePath: string): void { + // Two invocations: the combined `/inheritance:r /grant:r` form keeps %TEMP%'s inherited + // [SYSTEM, Administrators, user] as *explicit* ACEs on Windows Server, which left the file + // readable and every assertion below vacuous. Remove inheritance first, then grant. + expect(icacls(filePath, '/inheritance:r', '/q')).toBe(0) + expect(icacls(filePath, '/grant:r', `*${FOREIGN_SID}:(F)`, '/q')).toBe(0) + expect(readDenied(filePath), 'fixture should be unreadable').toBe(true) +} + +const describeOnWindows = process.platform === 'win32' && canDenyReads() ? describe : describe.skip + +describeOnWindows('a secure store that exists but cannot be read', () => { + let root: string + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'orca-unreadable-')) + }) + + afterAll(() => { + // Reset first: the tree is not removable while its files grant only Administrators. + icacls(root, '/reset', '/t', '/q') + removeTreeSync(root) + }) + + it('does not regenerate the E2EE keypair, which would un-pair every device', async () => { + const { loadOrCreateE2EEKeypair } = await import('./e2ee-keypair') + const { E2EE_KEYPAIR_FILENAME } = await import('./mobile-pairing-files') + const dir = join(root, 'e2ee') + mkdirSync(dir, { recursive: true }) + const filePath = join(dir, E2EE_KEYPAIR_FILENAME) + const original = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.alloc(32, 7).toString('base64'), + secretKeyB64: Buffer.alloc(32, 9).toString('base64') + }) + writeFileSync(filePath, original) + makeUnreadable(filePath) + + expect(() => loadOrCreateE2EEKeypair(dir)).toThrow(/Refusing to (regenerate|overwrite)/) + + // The point: the secret key is still the one every paired phone derived its shared secret from. + icacls(filePath, '/reset', '/q') + expect(readFileSync(filePath, 'utf8')).toBe(original) + }) + + it('does not erase the device registry, which would revoke every paired token', async () => { + const { DeviceRegistry } = await import('./device-registry') + const { DEVICE_REGISTRY_FILENAME } = await import('./mobile-pairing-files') + const dir = join(root, 'devices') + mkdirSync(dir, { recursive: true }) + const filePath = join(dir, DEVICE_REGISTRY_FILENAME) + const original = JSON.stringify([ + { + deviceId: 'device-1', + name: 'Phone', + token: 'bearer-token-that-must-survive', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + writeFileSync(filePath, original) + makeUnreadable(filePath) + + const registry = new DeviceRegistry(dir) + // Any mutator reaches save(); it must refuse rather than write the empty list it loaded. + expect(() => registry.addDevice('Another phone', 'mobile')).toThrow( + /Refusing to (regenerate|overwrite)/ + ) + + icacls(filePath, '/reset', '/q') + expect(readFileSync(filePath, 'utf8')).toBe(original) + }) + + it('does not blank the plugin secret vault on write', async () => { + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`), + decryptString: (buffer: Buffer) => buffer.toString().replace(/^enc:/, '') + } + })) + const { PluginSecretsStore } = await import('./../plugins/plugin-secrets-store') + const dir = join(root, 'plugin-secrets') + mkdirSync(dir, { recursive: true }) + const store = new PluginSecretsStore(dir, 'publisher.plugin') + // Reach the path the store computes rather than restating its layout here. + const filePath = (store as unknown as { filePath: string }).filePath + mkdirSync(join(filePath, '..'), { recursive: true }) + const original = JSON.stringify({ + version: 1, + format: 'electron-safe-storage-v1', + ciphertexts: { existing: Buffer.from('enc:keep-me').toString('base64') } + }) + writeFileSync(filePath, original) + makeUnreadable(filePath) + + expect(store.set('added', 'value')).toEqual({ ok: false, error: expect.any(String) }) + store.delete('existing') + + icacls(filePath, '/reset', '/q') + expect(readFileSync(filePath, 'utf8')).toBe(original) + vi.doUnmock('electron') + }) + it('does not blank the plugin KV store on write', async () => { + const { PluginKvStore } = await import('./../plugins/plugin-storage-store') + const dir = join(root, 'plugin-kv') + mkdirSync(dir, { recursive: true }) + const store = new PluginKvStore(dir, 'publisher.plugin', 'storage.json') + const filePath = (store as unknown as { filePath: string }).filePath + mkdirSync(join(filePath, '..'), { recursive: true }) + const original = JSON.stringify({ keep: 'me' }) + writeFileSync(filePath, original) + makeUnreadable(filePath) + + expect(store.set('added', 'value')).toEqual({ ok: false, error: expect.any(String) }) + store.delete('keep') + + icacls(filePath, '/reset', '/q') + expect(readFileSync(filePath, 'utf8')).toBe(original) + }) + + it('does not drop pending relay revocations', async () => { + const { RelayRevokeOutbox } = await import('./relay/relay-revoke-outbox') + const dir = join(root, 'relay') + mkdirSync(dir, { recursive: true }) + const filePath = join(dir, 'mobile-relay-revoke-outbox.json') + const original = JSON.stringify([ + { + relayHostId: 'host-1', + relayDeviceId: 'device-1', + ownerIdentityKey: 'owner-1', + reqId: 'req-1', + createdAt: 1 + } + ]) + writeFileSync(filePath, original) + makeUnreadable(filePath) + + const outbox = new RelayRevokeOutbox(dir) + expect(() => + outbox.enqueue({ + relayHostId: 'host-2', + relayDeviceId: 'device-2', + ownerIdentityKey: 'owner-2' + }) + ).toThrow(/Refusing to (regenerate|overwrite)/) + + icacls(filePath, '/reset', '/q') + expect(readFileSync(filePath, 'utf8')).toBe(original) + }) + + /** + * The one site that *deletes* rather than overwrites: a refresh failure plus an unreadable + * session used to fall past the `status === 'found'` guard into `clearOrcaCloudSession`. + */ + it('does not delete the account session it could not read', async () => { + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (buffer: Buffer) => buffer.toString() + } + })) + const { readOrcaCloudSession, getOrcaCloudSessionPath } = + await import('./../orca-profiles/profile-cloud-session-store') + const dir = join(root, 'profiles') + mkdirSync(dir, { recursive: true }) + const filePath = getOrcaCloudSessionPath('profile-1', dir) + mkdirSync(join(filePath, '..'), { recursive: true }) + const original = JSON.stringify({ version: 1, format: 'dev-plaintext-v1', savedAt: 1 }) + writeFileSync(filePath, original) + makeUnreadable(filePath) + + // The status the delete path keys off: `unreadable`, never `decrypt-failed`. + expect(readOrcaCloudSession('profile-1', dir).status).toBe('unreadable') + + icacls(filePath, '/reset', '/q') + expect(readFileSync(filePath, 'utf8')).toBe(original) + vi.doUnmock('electron') + }) +}) diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index 19109a444b8..d561878a538 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { execFile } from 'node:child_process' import { getWebSocketPort, inspectWindowsMobileFirewall, @@ -6,6 +7,9 @@ import { type WindowsMobileFirewallEnvironment } from './windows-mobile-firewall' +// Why: every other case injects `runPowerShell`, so only the argv case below reaches execFile. +vi.mock('node:child_process', () => ({ execFile: vi.fn() })) + function environment( runPowerShell: WindowsMobileFirewallEnvironment['runPowerShell'], overrides: Partial = {} @@ -179,6 +183,56 @@ describe('windows mobile firewall', () => { expect(repairScript).toContain('-EdgeTraversalPolicy Block') }) + it('keeps the elevated child encoded because Start-Process re-splits its ArgumentList', async () => { + // Why: `Start-Process -ArgumentList` joins the array into one ShellExecuteEx parameter + // string without quoting and PowerShell re-splits it on whitespace, which collapses runs + // of spaces. Measured on Windows 11: a `-Command` payload turned `C:\My App\Orca.exe` + // into `C:\My App\Orca.exe`, i.e. a firewall rule for the wrong program. Base64 is the + // only form that survives that hop, so this site must not follow the local runner. + const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') + await repairWindowsMobileFirewall( + 6769, + environment(runPowerShell, { executablePath: 'C:\\My App\\Orca.exe' }) + ) + + const outerScript = runPowerShell.mock.calls[0]![0] as string + expect(outerScript).toContain("'-EncodedCommand'") + expect(outerScript).not.toContain("'-Command'") + expect(outerScript).toContain('-Verb RunAs') + + const encoded = outerScript.match(/'-EncodedCommand', '([^']+)'/)?.[1] + const repairScript = Buffer.from(encoded!, 'base64').toString('utf16le') + expect(repairScript).toContain("-Program 'C:\\My App\\Orca.exe'") + }) + + it('runs the local PowerShell over argv with a plain -Command script', async () => { + // Why: execFile reaches CreateProcess with no shell in between, so the script needs no + // base64 armouring, and argv preserves runs of spaces that the elevated hop cannot. + // `-EncodedCommand` here was pure EDR signal. + const execFileMock = vi.mocked(execFile) + execFileMock.mockImplementation(((_file, _args, _options, callback) => { + callback(null, '{"privateFirewallEnabled":true,"networkCategory":"Private"}', '') + return {} + }) as unknown as typeof execFile) + + await inspectWindowsMobileFirewall(6768, undefined, { + platform: 'win32', + isPackaged: true, + executablePath: 'C:\\My App\\Orca.exe', + systemRoot: 'C:\\Windows' + }) + + const [file, args] = execFileMock.mock.calls[0]! + expect(file).toMatch(/WindowsPowerShell\\v1\.0\\powershell\.exe$/i) + expect(args!.slice(0, 3)).toEqual(['-NoProfile', '-NonInteractive', '-Command']) + expect(args).not.toContain('-EncodedCommand') + expect(args).not.toContain('-ExecutionPolicy') + // The script travels as ONE argv element, so its spaces and newlines survive verbatim. + expect(args).toHaveLength(4) + expect(args![3]).toContain("-Program 'C:\\My App\\Orca.exe'") + expect(args![3]).toContain('\n') + }) + it('distinguishes a cancelled UAC prompt from repair failure', async () => { await expect( repairWindowsMobileFirewall( diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index 88b885ab5f0..c90a025cb14 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -229,6 +229,11 @@ Get-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -ErrorAction Si New-NetFirewallRule -Name ${quotePowerShell(FIREWALL_RULE_NAME)} -DisplayName ${quotePowerShell(FIREWALL_RULE_DISPLAY_NAME)} -Description 'Allows Orca Mobile to connect to this Orca desktop on private networks.' -Direction Inbound -Action Allow -Enabled True -Profile Private -Protocol TCP -LocalPort ${port} -Program ${quotePowerShell(executablePath)} -EdgeTraversalPolicy Block | Out-Null` } +// Why the elevated child keeps `-EncodedCommand` while the local runner does not: `Start-Process +// -ArgumentList` joins its array into one ShellExecuteEx parameter string without quoting, and +// PowerShell then re-splits it on whitespace — measured to collapse `C:\My App\...` to +// `C:\My App\...`, which would silently write the firewall rule for the wrong program. Node's +// argv path (createPowerShellRunner) preserves runs of spaces, so only this hop needs base64. function buildElevationScript(powershellPath: string, encodedRepairScript: string): string { return `$ErrorActionPreference = 'Stop' try { @@ -257,7 +262,9 @@ function createPowerShellRunner(systemRoot?: string): PowerShellRunner { new Promise((resolve, reject) => { execFile( powershellPath, - ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShell(script)], + // Why: argv reaches CreateProcess with no shell in between, so the script needs no base64 + // armouring — and plain `-Command` keeps this off EDR's encoded-PowerShell heuristics. + ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout) => { if (error) { diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 3750884f84f..7dfe0144406 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -273,6 +273,22 @@ describe('worktree scan admin-fingerprint gate', () => { } }) + it('resolves a just-created id after invalidation even within both cache TTLs', async () => { + const { runtime, list } = makeRuntime() + listWorktreesStrictMock.mockResolvedValueOnce([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } + ]) + await list() + await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).rejects.toThrow( + 'selector_not_found' + ) + runtime.invalidateWorktreeCatalog(REPO_ID) + await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).resolves.toMatchObject({ + id: WORKTREE_ID + }) + expect(scanCount()).toBe(2) + }) + it('scans when the probe cannot describe the repo', async () => { vi.useFakeTimers() try { diff --git a/src/main/skills/skill-root-file-walk.test.ts b/src/main/skills/skill-root-file-walk.test.ts index 3ca80bdb495..ad84eced6b6 100644 --- a/src/main/skills/skill-root-file-walk.test.ts +++ b/src/main/skills/skill-root-file-walk.test.ts @@ -45,6 +45,34 @@ describe('findSkillFiles', () => { expect(found).toEqual([join(root, 'near', 'SKILL.md')]) }) + it('does not stat directory links beyond the depth bound but still follows in-bound links', async () => { + const base = await makeTree() + const root = join(base, 'skills') + const edge = join(root, 'a', 'b', 'c', 'd') + const target = join(base, 'linked') + await writeFileAt(join(edge, 'SKILL.md')) + await writeFileAt(join(target, 'SKILL.md')) + for (let index = 0; index < 32; index += 1) { + await symlink( + target, + join(edge, `link${index.toString().padStart(2, '0')}`), + process.platform === 'win32' ? 'junction' : 'dir' + ) + } + const statPaths: string[] = [] + onStat = async (path) => { + statPaths.push(path) + } + + expect(await findSkillFiles(root, 4)).toEqual([join(edge, 'SKILL.md')]) + expect(statPaths).toEqual([]) + expect(await findSkillFiles(root, 5)).toEqual([ + join(edge, 'SKILL.md'), + join(edge, 'link00', 'SKILL.md') + ]) + expect(statPaths).toHaveLength(32) + }) + it('returns nothing for a missing root rather than throwing', async () => { expect(await findSkillFiles(join(await makeTree(), 'absent'), 4)).toEqual([]) }) diff --git a/src/main/skills/skill-root-file-walk.ts b/src/main/skills/skill-root-file-walk.ts index 261a650a967..4be1843f602 100644 --- a/src/main/skills/skill-root-file-walk.ts +++ b/src/main/skills/skill-root-file-walk.ts @@ -43,9 +43,6 @@ export async function findSkillFiles( // indistinguishable from a genuinely small root, and a caller that cached it // would publish "these skills no longer exist". signal?.throwIfAborted() - if (!isWithinDepth(rootPath, dirPath, maxDepth)) { - return - } let resolvedDirPath: string try { resolvedDirPath = await realpath(dirPath) @@ -61,6 +58,8 @@ export async function findSkillFiles( if (!entries) { return } + // Directory entry names add one segment, so siblings share the depth verdict. + let childrenWithinDepth: boolean | undefined for (const entry of entries) { signal?.throwIfAborted() // Why: a staged sibling sits directly in a scanned root, so without this a @@ -86,10 +85,15 @@ export async function findSkillFiles( continue } if (entry.isDirectory()) { - await visit(entryPath) + if ((childrenWithinDepth ??= isWithinDepth(rootPath, entryPath, maxDepth))) { + await visit(entryPath) + } continue } - if (entry.isSymbolicLink()) { + if ( + entry.isSymbolicLink() && + (childrenWithinDepth ??= isWithinDepth(rootPath, entryPath, maxDepth)) + ) { // Why: users commonly symlink agent skill dirs across providers; follow // directory links but guard by realpath so recursive links cannot loop. let linksToDirectory = false diff --git a/src/main/speech/model-manager-stream-cleanup.test.ts b/src/main/speech/model-manager-stream-cleanup.test.ts index dcebaca37a3..34b7aa893d8 100644 --- a/src/main/speech/model-manager-stream-cleanup.test.ts +++ b/src/main/speech/model-manager-stream-cleanup.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' @@ -34,15 +34,17 @@ describe('ModelManager stream cleanup', () => { netRequestMock.mockReset() }) - it('removes response progress listeners after a model download finishes', async () => { + it('reuses the idle timer and removes progress listeners after a fragmented download', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout') try { const response = new PassThrough() as PassThrough & { statusCode: number headers: Record } response.statusCode = 200 - response.headers = { 'content-length': '4' } + response.headers = { 'content-length': '1000' } const responseHandlers: ((response: unknown) => void)[] = [] const request = { abort: vi.fn(() => request), @@ -66,16 +68,26 @@ describe('ModelManager stream cleanup', () => { const download = manager.downloadFile( 'https://example.com/model.bin', join(dir, 'model.bin'), - 4, + 1000, 'm', () => false ) - response.write(Buffer.from('ab')) - response.end(Buffer.from('cd')) + await vi.advanceTimersByTimeAsync(60_000) + for (let index = 0; index < 1000; index += 1) { + response.write(Buffer.from('a')) + } + await vi.advanceTimersByTimeAsync(119_999) + expect(request.abort).not.toHaveBeenCalled() + response.end() await expect(download).resolves.toBeUndefined() expect(response.listenerCount('data')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + expect(readFileSync(join(dir, 'model.bin'), 'utf8')).toBe('a'.repeat(1000)) + expect(timeoutSpy.mock.calls.filter(([, delay]) => delay === 120_000)).toHaveLength(1) } finally { + timeoutSpy.mockRestore() + vi.useRealTimers() rmSync(dir, { recursive: true, force: true }) } }) diff --git a/src/main/speech/speech-model-http-download.ts b/src/main/speech/speech-model-http-download.ts index ae2bae9648e..3bd2f24ab97 100644 --- a/src/main/speech/speech-model-http-download.ts +++ b/src/main/speech/speech-model-http-download.ts @@ -71,8 +71,11 @@ export abstract class SpeechModelHttpDownload { request = null } const resetIdleTimeout = (): void => { - clearIdleTimeout() - idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS) + if (idleTimeout) { + idleTimeout.refresh() + } else { + idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS) + } } const resolveOnce = (): void => { if (settled) { diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index fe3ba7e388d..5a028e68fd9 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -194,9 +194,11 @@ describe('SyncDatabase read-only opens under contention', () => { const contended = await contendedDatabase(10_000) const startedAt = Date.now() + const reader = new SyncDatabase(contended.path, { readonly: true }) + openDatabases.push(reader) let thrown: unknown try { - new SyncDatabase(contended.path, { readonly: true }).prepare('SELECT id FROM items').all() + reader.prepare('SELECT id FROM items').all() } catch (error) { thrown = error } @@ -210,11 +212,9 @@ describe('SyncDatabase read-only opens under contention', () => { const contended = await contendedDatabase(10_000) const startedAt = Date.now() - expect(() => - new SyncDatabase(contended.path, { readonly: true, timeout: 400 }) - .prepare('SELECT id FROM items') - .all() - ).toThrow(/database is locked/) + const reader = new SyncDatabase(contended.path, { readonly: true, timeout: 400 }) + openDatabases.push(reader) + expect(() => reader.prepare('SELECT id FROM items').all()).toThrow(/database is locked/) expect(Date.now() - startedAt).toBeGreaterThanOrEqual(350) }) diff --git a/src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts b/src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts new file mode 100644 index 00000000000..f87d1e6f778 --- /dev/null +++ b/src/main/ssh/ssh-file-stream-inactivity-deadline.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSshFileStreamInactivityDeadline } from './ssh-file-stream-inactivity-deadline' +import type { SystemPowerLifecycleListener } from '../system-power-lifecycle' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('SSH file stream inactivity timer', () => { + it('reuses one timer while retaining the deadline of the latest chunk', () => { + vi.useFakeTimers() + const allocate = vi.spyOn(globalThis, 'setTimeout') + const onTimeout = vi.fn() + const unsubscribe = vi.fn() + const deadline = createSshFileStreamInactivityDeadline(onTimeout, (listener) => { + listener.onResume() + return unsubscribe + }) + deadline.reset() + vi.advanceTimersByTime(30_000) + for (let chunk = 0; chunk < 1000; chunk += 1) { + deadline.reset() + } + expect(allocate).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(59_999) + expect(onTimeout).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases on suspend and creates a fresh timer on resume', () => { + vi.useFakeTimers() + const allocate = vi.spyOn(globalThis, 'setTimeout') + const onTimeout = vi.fn() + let power!: SystemPowerLifecycleListener + const deadline = createSshFileStreamInactivityDeadline(onTimeout, (listener) => { + power = listener + listener.onResume() + return vi.fn() + }) + deadline.reset() + vi.advanceTimersByTime(30_000) + power.onSuspend() + for (let chunk = 0; chunk < 1000; chunk += 1) { + deadline.reset() + } + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(120_000) + expect(onTimeout).not.toHaveBeenCalled() + power.onResume() + expect(allocate).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(59_999) + expect(onTimeout).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears the timer and subscription and supports a later reset', () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const unsubscribe = vi.fn() + const subscribe = vi.fn((listener: SystemPowerLifecycleListener) => { + listener.onResume() + return unsubscribe + }) + const deadline = createSshFileStreamInactivityDeadline(onTimeout, subscribe) + deadline.reset() + deadline.clear() + deadline.clear() + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(120_000) + expect(onTimeout).not.toHaveBeenCalled() + deadline.reset() + expect(subscribe).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + deadline.clear() + expect(unsubscribe).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/ssh/ssh-file-stream-inactivity-deadline.ts b/src/main/ssh/ssh-file-stream-inactivity-deadline.ts index 5470e8121fe..cbb9839f9b3 100644 --- a/src/main/ssh/ssh-file-stream-inactivity-deadline.ts +++ b/src/main/ssh/ssh-file-stream-inactivity-deadline.ts @@ -24,10 +24,13 @@ export function createSshFileStreamInactivityDeadline( } } const arm = (): void => { - clearTimer() if (suspended) { return } + if (timer) { + timer.refresh() + return + } timer = setTimeout(onTimeout, SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS) timer.unref?.() } diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts index 6a50dc28a72..0a8b26aa779 100644 --- a/src/main/ssh/ssh-git-response-stream-reader.ts +++ b/src/main/ssh/ssh-git-response-stream-reader.ts @@ -90,7 +90,10 @@ export function requestGitStreamable( // killed, but a wedged stream (no frames arriving) rejects instead of // hanging the caller forever. const armInactivity = (): void => { - clearInactivity() + if (inactivityTimer) { + inactivityTimer.refresh() + return + } inactivityTimer = setTimeout(() => { fail( new GitResponseStreamError( diff --git a/src/main/ssh/ssh-git-stream-idle-timer.test.ts b/src/main/ssh/ssh-git-stream-idle-timer.test.ts new file mode 100644 index 00000000000..7cd5207c63a --- /dev/null +++ b/src/main/ssh/ssh-git-stream-idle-timer.test.ts @@ -0,0 +1,80 @@ +import { expect, it, vi } from 'vitest' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { requestGitStreamable } from './ssh-git-response-stream-reader' + +it.each(['end', 'abort', 'timeout'] as const)( + 'reuses the idle deadline across 1000 chunks and cleans up on %s', + async (finish) => { + vi.useFakeTimers() + const setTimer = vi.spyOn(globalThis, 'setTimeout') + try { + const listeners = new Map) => void>() + const controller = new AbortController() + const content = 'x'.repeat(998) + const encoded = Buffer.from(JSON.stringify(content)) + const notify = vi.fn() + const mux = { + request: vi.fn(async () => ({ + __orcaGitResponseStream: { streamId: 7, totalBytes: encoded.length, chunkCount: 1000 } + })), + isDisposed: () => false, + notify, + onDispose: () => () => {}, + onNotificationByMethod: ( + method: string, + callback: (params: Record) => void + ) => { + listeners.set(method, callback) + return () => listeners.delete(method) + } + } + const promise = requestGitStreamable( + mux as unknown as SshChannelMultiplexer, + 'git.diff', + {}, + { + signal: controller.signal + } + ) + const outcome = promise.then( + (value) => ({ value }), + (error: Error) => ({ error: error.message }) + ) + await vi.advanceTimersByTimeAsync(15_000) + for (let seq = 0; seq < encoded.length; seq++) { + listeners.get('git.responseChunk')!({ + streamId: 7, + seq, + data: encoded.subarray(seq, seq + 1).toString('base64') + }) + } + await vi.advanceTimersByTimeAsync(29_999) + expect(listeners.size).toBe(3) + expect(notify.mock.calls.filter(([method]) => method === 'git.responseAck')).toHaveLength( + 1000 + ) + const allocations = setTimer.mock.calls.filter(([, delay]) => delay === 30_000).length + if (finish === 'end') { + listeners.get('git.responseEnd')!({ streamId: 7 }) + expect(await outcome).toEqual({ value: content }) + } else if (finish === 'abort') { + controller.abort() + expect(await outcome).toEqual({ error: 'Request was cancelled' }) + } else { + await vi.advanceTimersByTimeAsync(1) + expect(await outcome).toEqual({ + error: 'Git response stream stalled (>30000ms without data)' + }) + } + expect(allocations).toBe(1) + expect(vi.getTimerCount()).toBe(0) + expect(listeners.size).toBe(0) + expect( + notify.mock.calls.filter(([method]) => method === 'git.cancelResponseStream') + ).toHaveLength(finish === 'end' ? 0 : 1) + } finally { + setTimer.mockRestore() + vi.useRealTimers() + } + } +) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index a1f85fc08d8..dc809804127 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -209,6 +209,7 @@ export function waitForSentinel( const afterSentinelOffset = sentinelIdx + RELAY_SENTINEL_BUFFER.length - bufferedStdout.length const afterSentinel = data.subarray(Math.max(0, afterSentinelOffset)) + bufferedStdout = Buffer.alloc(0) if (afterSentinel.length > 0) { pendingAfterSentinel = afterSentinel @@ -258,7 +259,7 @@ export function waitForSentinel( return } - bufferedStdout = bufferedStdout.length === 0 ? data : Buffer.concat([bufferedStdout, data]) + bufferedStdout = startupStdout }) }) } diff --git a/src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts b/src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts new file mode 100644 index 00000000000..b05c8a71d92 --- /dev/null +++ b/src/main/ssh/ssh-relay-sentinel-copy-budget.test.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from 'node:events' +import type { ClientChannel } from 'ssh2' +import { expect, it, vi } from 'vitest' +import { RELAY_SENTINEL } from './relay-protocol' +import { waitForSentinel } from './ssh-relay-deploy-helpers' + +it.each([1, 256])('copies each startup prefix once across %i chunks', async (chunks) => { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + stdin: { write: vi.fn(() => true) }, + close: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + }) + const pending = waitForSentinel(channel as unknown as ClientChannel) + const chunk = Buffer.alloc((64 * 1024) / chunks, 120) + const concat = vi.spyOn(Buffer, 'concat') + let calls = 0 + let copied = 0 + try { + for (let i = 0; i < chunks; i++) { + channel.emit('data', chunk) + } + calls = concat.mock.calls.length + copied = concat.mock.calls.reduce( + (sum, [buffers]) => sum + buffers.reduce((bytes, buffer) => bytes + buffer.length, 0), + 0 + ) + } finally { + concat.mockRestore() + } + channel.emit('data', Buffer.from(`${RELAY_SENTINEL}first-frame`)) + const transport = await pending + const received: string[] = [] + transport.onData((bytes) => received.push(bytes.toString())) + expect(received).toEqual(['first-frame']) + expect(channel.close).not.toHaveBeenCalled() + expect(calls).toBe(chunks - 1) + expect(copied).toBe(chunk.length * ((chunks * (chunks + 1)) / 2 - 1)) +}) + +it.each(Array.from({ length: RELAY_SENTINEL.length + 1 }, (_, i) => i))( + 'preserves the marker and binary payload when split at byte %i', + async (split) => { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + stdin: { write: vi.fn(() => true) }, + close: vi.fn() + }) + const pending = waitForSentinel(channel as unknown as ClientChannel) + const marker = Buffer.from(RELAY_SENTINEL) + const payload = Buffer.from([0, 255, 128, 10, 13, 1]) + channel.emit('data', Buffer.alloc(63 * 1024, 120)) + channel.emit('data', marker.subarray(0, split)) + channel.emit('data', Buffer.concat([marker.subarray(split), payload])) + const transport = await pending + const received: Buffer[] = [] + transport.onData((bytes) => received.push(bytes)) + expect(Buffer.concat(received)).toEqual(payload) + expect(channel.close).not.toHaveBeenCalled() + } +) diff --git a/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts new file mode 100644 index 00000000000..cd0503348d0 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-dispatch-refusal-passthrough.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' + +// Why: the SSH bridge captures the host CLI child's stdout and exit code without reparsing; this +// pins that a typed refusal envelope and its nonzero exit reach the remote agent unchanged. +it('relays typed dispatch refusal codes from the host CLI unchanged', async () => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType; on: ReturnType } + kill: ReturnType + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { end: vi.fn(), on: vi.fn() } + child.kill = vi.fn() + const spawn = vi.fn(() => child) + const refusal = { + id: 'rpc_1', + ok: false, + error: { + code: 'task_not_startable', + message: 'Task task_1 is pending; only ready tasks can be dispatched', + data: { taskId: 'task_1', status: 'pending', unmetDependencies: ['task_0'] } + }, + _meta: { runtimeId: 'runtime_1' } + } + + const resultPromise = runRemoteOrcaCli( + new OrcaRuntimeService(), + { + argv: ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_w', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + spawn: spawn as never + } + ) + + const stdout = `${JSON.stringify(refusal, null, 2)}\n` + await Promise.resolve() + child.stdout.emit('data', Buffer.from(stdout)) + child.emit('close', 1) + + expect(await resultPromise).toEqual({ stdout, stderr: '', exitCode: 1 }) +}) diff --git a/src/main/ssh/ssh-remote-powershell.test.ts b/src/main/ssh/ssh-remote-powershell.test.ts new file mode 100644 index 00000000000..1cfb2afa1a8 --- /dev/null +++ b/src/main/ssh/ssh-remote-powershell.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { scanSourceTree, stripComments } from '../../shared/source-scan/source-tree-scan' +import { powerShellCommand } from './ssh-remote-powershell' + +function decodePayload(command: string): string { + const encoded = command.match(/ -EncodedCommand (\S+)$/)?.[1] + if (!encoded) { + throw new Error(`no -EncodedCommand payload in: ${command}`) + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +/** + * This one helper builds the command line for every remote-Windows SSH call site + * (relay deploy, install locks, upload staging, GC claim, browse, CLI launch), so + * its switches are worth pinning. + */ +describe('powerShellCommand', () => { + it('spells no -ExecutionPolicy switch', () => { + const command = powerShellCommand('exit 0') + const switches = command.replace(/ -EncodedCommand \S+$/, '') + + // Why: `-EncodedCommand` is not execution-policy gated — only `-File` is — so the switch + // was a no-op, and `-ExecutionPolicy Bypass` beside base64 is among the most heavily + // EDR-flagged PowerShell command lines there is. + expect(switches).not.toMatch(/-ExecutionPolicy/i) + expect(switches).not.toMatch(/Bypass/i) + expect(switches).toBe('powershell.exe -NoProfile -NonInteractive') + }) + + it('keeps the base64 payload the remote shell cannot rewrite', () => { + // Why: this string is re-parsed by the remote host's sshd DefaultShell, which is + // cmd.exe on a stock Windows OpenSSH install. Base64 is load-bearing here. + const command = powerShellCommand("Write-Output 'a & b' | Out-String") + + expect(command).toMatch( + /^powershell\.exe -NoProfile -NonInteractive -EncodedCommand [A-Za-z0-9+/=]+$/ + ) + expect(decodePayload(command)).toBe("Write-Output 'a & b' | Out-String") + }) +}) + +const MAIN_DIR = join(import.meta.dirname, '..') + +// Why: execution policy gates loading script FILES and nothing else, so dropping +// `-ExecutionPolicy Bypass` is a no-op exactly while no remote payload loads one. That +// invariant is what makes the switch safe to omit, and it was previously guarded by nothing: +// a future payload that dot-sourced or used `-File` would fail only on a remote host whose +// LocalMachine policy is Restricted/AllSigned. See the invariant note on `powerShellCommand`. +// Every pattern is case-insensitive: PowerShell switches and cmdlet names are, and Windows +// paths are, so `-file`, `import-module` and `DEPLOY.PS1` are all legitimate spellings that a +// case-sensitive pattern would wave through. Verified to add no false positive across the real +// importers. Each entry carries the fixtures it must catch AND the near-misses it must not, so +// a future tightening cannot quietly trade one for the other. +// +// Limit: this is a source-text scan, so a script file reached only through a variable +// (`& $scriptPath`) never appears in source and no pattern here can catch it — this narrows +// the hole rather than sealing it. The invariant note on `powerShellCommand` covers the rest. +// +// Matched against `stripComments`, the shared quote-tracking stripper, so a construct named in +// prose is not counted as code. A line-anchored regex pair cannot do this job: it either eats +// live code by pairing a `/*` inside a glob string with a later comment close, or — anchoring +// to avoid that — skips every trailing comment. Quote state is the only fix. +const POLICY_GATED_CONSTRUCTS = [ + { + label: 'a PowerShell script file (.ps1/.psm1)', + pattern: /\.psm?1\b/i, + catches: [ + `powerShellCommand("$script = 'C:\\tools\\deploy.ps1'")`, + `powerShellCommand("Import-Module '$dir\\orca.psm1'")`, + `powerShellCommand("& '$root\\DEPLOY.PS1'")` + ], + ignores: [`const build = 'artifact.ps10'`] + }, + { + label: 'Import-Module', + pattern: /\bImport-Module\b/i, + catches: [ + `powerShellCommand("Import-Module 'NetSecurity'")`, + `powerShellCommand("import-module $modulePath")` + ], + ignores: [`const name = 'Import-ModuleList'`] + }, + { + // Anchored to a token boundary: a bare /-File\b/i also matches `--credential-file`, + // `--log-file` and `--body-file`, which are real arguments in three of these importers. + label: 'the -File switch', + pattern: /(^|[\s'"`([{,])-File\b/i, + catches: [ + `runRemote("powershell.exe -NoProfile -File 'C:\\x.ps1'")`, + `runRemote("powershell.exe -file $scriptVar")`, + `runRemote(["-NoProfile", "-File", scriptVar])` + ], + ignores: [`fetchWith("--credential-file", path)`, `run("--log-file $p --body-file $b")`] + }, + { + // The quote/backtick prefixes matter: a dot-source in a generated payload usually sits at + // the very start of a TS string literal — `powerShellCommand(". '$x'")` — not after a `;`. + label: 'dot-sourcing', + pattern: /(^|[;{'"`]|\n)[ \t]*\.[ \t]+['"$]/, + catches: [ + `powerShellCommand(". '$profileScript'")`, + `powerShellCommand("$ErrorActionPreference = 'Stop'; . '$profile'")`, + `powerShellCommand(". $profileScript")` + ], + ignores: [ + `cp -a $sourcePath/. $destinationPath/`, + `Host key verification failed for $displayHost. $detail` + ] + } +] as const + +describe('remote PowerShell payload invariant', () => { + // `scanSourceTree` is the shared walk: it skips node_modules/dist/out/build/.git, + // dot-directories and `__fixtures__`, and excludes tests by the shared `isTestFile` (which + // also covers `.spec.ts`, `__tests__/` and `-test-harness.ts`). A hand-rolled walk that got + // any of those wrong would move the floor below, which is this guard's own goalpost. + const importers = scanSourceTree(MAIN_DIR).filter((file) => + file.source.includes('ssh-remote-powershell') + ) + + it('finds the modules that build remote payloads', () => { + // Guards the scan itself: a resolution change that emptied this list would make every + // assertion below vacuously pass. 15 importers today, re-derived against the shared walk. + expect(importers.length).toBeGreaterThan(10) + }) + + it.each(POLICY_GATED_CONSTRUCTS)( + 'loads no remote payload through $label', + ({ label, pattern }) => { + const offenders = importers + .filter((file) => pattern.test(stripComments(file.source))) + .map((file) => file.relativePath) + + expect( + offenders, + `${offenders.join(', ')} uses ${label}, which IS execution-policy gated on the remote ` + + 'host. Do not restore `-ExecutionPolicy Bypass` to the command line (a GPO scope ' + + 'beats it). Set the policy in-payload at process scope instead — see the note on ' + + 'powerShellCommand.' + ).toEqual([]) + } + ) + + // Why: these patterns only earn trust if they fire on a real violation spelled the way a + // generated payload spells it — inside a TS string literal — and stay quiet on the near + // misses. Both halves are load-bearing: an earlier dot-source pattern passed a `;`-prefixed + // sample but missed `powerShellCommand(". '$x'")`, and the obvious case-insensitive fix for + // `-File` matches `--credential-file` in three real importers. A fixture written from the + // pattern confirms the pattern; these are written from the requirement. + it.each(POLICY_GATED_CONSTRUCTS)( + 'detects $label wherever it is spelled', + ({ pattern, catches, ignores }) => { + for (const sample of catches) { + expect(pattern.test(sample), `should catch: ${sample}`).toBe(true) + } + for (const sample of ignores) { + expect(pattern.test(sample), `should ignore: ${sample}`).toBe(false) + } + } + ) +}) diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts index 420223ced29..31587bcc668 100644 --- a/src/main/ssh/ssh-remote-powershell.ts +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -18,6 +18,27 @@ const WINDOWS_REMOTE_COMMAND_LINE_BUDGET_CHARS = 8_000 */ export type WindowsPowerShellExecutable = 'powershell.exe' | 'pwsh.exe' +// Why: `-EncodedCommand` is not execution-policy gated (only `-File` is), so `-ExecutionPolicy +// Bypass` was a no-op here — and it is one of the most heavily EDR-flagged PowerShell tokens. +// The base64 stays: this string is re-parsed by the remote host's default SSH shell, which may +// be cmd.exe, PowerShell, or bash. +// +// INVARIANT — no remote payload may load a PowerShell *script file*. +// +// Execution policy has only ever gated loading script files (2.0 through 7.x). Inline +// statements, `& some.exe` and `Add-Type -TypeDefinition` are never gated, which is what makes +// dropping the switch a no-op for every payload we send today — the compressed path below stays +// inline too, since `Invoke-Expression` on a decompressed string loads no file. Loading a script +// file is the one thing the dropped switch actually covered, so a payload that dot-sources, runs +// `& '.ps1'`, calls `Import-Module '.psm1'`, or passes `-File` would silently fail on a +// remote host whose LocalMachine policy is Restricted/AllSigned with no GPO — a break that +// surfaces on someone else's machine, not ours. +// +// If you ever need one, do NOT restore the command-line switch (it loses to a GPO scope anyway, +// so it never covered the locked-down case): set the policy in-payload at process scope, the way +// `buildWindowsStartupCommand` in src/shared/setup-agent-sequencing.ts does. +// +// Enforced by the ratchet in ssh-remote-powershell.test.ts, which scans every importer. export function powerShellCommand( script: string, executable: WindowsPowerShellExecutable = 'powershell.exe' @@ -38,7 +59,7 @@ export function powerShellCommand( } function encodedPowerShellCommand(script: string, executable: WindowsPowerShellExecutable): string { - return `${executable} -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` + return `${executable} -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}` } /** Orca-prefixed names so the payload can never shadow the bootstrap's own state. */ diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts index 106129feb8f..548fabf5a73 100644 --- a/src/main/system-fonts.test.ts +++ b/src/main/system-fonts.test.ts @@ -70,6 +70,22 @@ describe('listSystemFontFamilies', () => { }) }) + it('spawns the Windows font script without an -ExecutionPolicy switch', async () => { + // Why: execution policy gates script *files*, never -Command, so the switch + // was a no-op -- and it is the highest-weighted token on the command lines + // Defender flags (#17858). + await withPlatform('win32', async () => { + runProcessMock.mockResolvedValue(ok('Consolas\n')) + const { listSystemFontFamilies } = await import('./system-fonts') + await listSystemFontFamilies() + + const args = runProcessMock.mock.calls[0]?.[0].args ?? [] + expect(args).toContain('-Command') + expect(args).not.toContain('-ExecutionPolicy') + expect(args).not.toContain('Bypass') + }) + }) + it('runs PowerShell by absolute path on Windows', async () => { // Why: a bare `powershell.exe` resolves against the child's PATH, which is // not the user's under Electron. Where policy has pruned the System32 entry diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index f841e22a579..73d5ef9f993 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -89,7 +89,8 @@ $fonts.Families | ForEach-Object { $_.Name } return execFileText( windowsPowerShellPath(), - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + // Why: policy gates script *files*, not -Command, so the switch was a Defender-weighted no-op. + ['-NoProfile', '-NonInteractive', '-Command', script], 8 * 1024 * 1024 ).then((output) => uniqueSorted( diff --git a/src/main/window/createMainWindow-startup-reveal.test.ts b/src/main/window/createMainWindow-startup-reveal.test.ts index 1200132d319..bd7eeadc6b2 100644 --- a/src/main/window/createMainWindow-startup-reveal.test.ts +++ b/src/main/window/createMainWindow-startup-reveal.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', async () => (await import('./createMainWindow-test-harness')).electronModuleMock() @@ -22,6 +22,14 @@ import { withPlatform } from './createMainWindow-test-harness' +// These cases exercise foreground behavior against Electron mocks. +beforeEach(() => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined) + vi.stubEnv('ORCA_E2E_HEADLESS', undefined) + vi.stubEnv('ORCA_E2E_HEADFUL', undefined) +}) +afterEach(() => vi.unstubAllEnvs()) + describe('createMainWindow', () => { beforeEach(() => { resetMainWindowMocks() @@ -78,6 +86,34 @@ describe('createMainWindow', () => { } } + it.each(['darwin', 'linux', 'win32'] as const)( + 'keeps explicit background startup hidden through ready/load/fallback on %s', + (platform) => { + vi.useFakeTimers() + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + const showInactive = vi.fn() + Object.assign(browserWindowInstance, { showInactive }) + try { + withPlatform(platform, () => { + createMainWindow(createStartupRevealStore(true) as never, { revealOnDidFinishLoad: true }) + const revealAfterLoad = browserWindowInstance.webContents.on.mock.calls.find( + ([event]) => event === 'did-finish-load' + )?.[1] + expect(revealAfterLoad).toBeTypeOf('function') + revealAfterLoad?.() + windowHandlers['ready-to-show']() + vi.advanceTimersByTime(10_000) + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(showInactive).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + } finally { + vi.unstubAllEnvs() + } + } + ) + it('ignores duplicate ready-to-show events after startup maximize has already run', () => { const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 86735811bb8..0e64d573f76 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -171,6 +171,14 @@ function makeStore(ui: Record = {}): { const RENDERER_URL = 'http://localhost:5173' +// These cases exercise foreground behavior against Electron mocks. +beforeEach(() => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined) + vi.stubEnv('ORCA_E2E_HEADLESS', undefined) + vi.stubEnv('ORCA_E2E_HEADFUL', undefined) +}) +afterEach(() => vi.unstubAllEnvs()) + describe('createOrFocusDashboardPopout', () => { beforeEach(() => { instances.length = 0 diff --git a/src/main/window/focus-existing-window.test.ts b/src/main/window/focus-existing-window.test.ts index babb9a15490..697b423ab37 100644 --- a/src/main/window/focus-existing-window.test.ts +++ b/src/main/window/focus-existing-window.test.ts @@ -1,5 +1,5 @@ import type { App, BrowserWindow } from 'electron' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { focusExistingMainWindow } from './focus-existing-window' type FakeWindowOptions = { @@ -78,7 +78,38 @@ function makeTimer(): { } } +// These cases exercise foreground behavior against Electron mocks. +beforeEach(() => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', undefined) + vi.stubEnv('ORCA_E2E_HEADLESS', undefined) + vi.stubEnv('ORCA_E2E_HEADFUL', undefined) +}) +afterEach(() => vi.unstubAllEnvs()) + describe('focusExistingMainWindow', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'never restores or activates a background window on %s', + (platform) => { + vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1') + vi.stubEnv('ORCA_E2E_FOREGROUND', '1') + const app = makeFakeApp() + const window = makeFakeWindow({ minimized: true }) + const timer = makeTimer() + focusExistingMainWindow({ + app, + getWindow: () => window, + openWindow: vi.fn(), + platform, + setTimeout: timer.setTimeout + }) + expect(app.focus).not.toHaveBeenCalled() + for (const call of Object.values(window.calls)) { + expect(call).not.toHaveBeenCalled() + } + expect(timer.scheduledMs()).toEqual([]) + } + ) + it('aggressively foregrounds an existing Windows window on second launch', () => { const app = makeFakeApp() const window = makeFakeWindow() diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 4cadb49a743..903e6a8b321 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -1,5 +1,9 @@ import type { App, BrowserWindow } from 'electron' -import { isBackgroundLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy' +import { + isBackgroundLaunch, + isWindowlessLaunch, + showWindowWithoutStealingFocus +} from './foreground-activation-policy' type FocusTimer = (callback: () => void, ms: number) => unknown @@ -34,7 +38,7 @@ function safelyFocusApp(app: Pick): void { } export function safelyRevealWindow(window: BrowserWindow): void { - if (window.isDestroyed()) { + if (window.isDestroyed() || isWindowlessLaunch()) { return } if (window.isMinimized()) { diff --git a/src/main/window/foreground-activation-policy.test.ts b/src/main/window/foreground-activation-policy.test.ts index 0a45f00387e..3b33c9be881 100644 --- a/src/main/window/foreground-activation-policy.test.ts +++ b/src/main/window/foreground-activation-policy.test.ts @@ -32,6 +32,10 @@ describe('isBackgroundLaunch', () => { expect(isBackgroundLaunch({})).toBe(false) }) + it('keeps an explicit background request despite inherited foreground flags', () => { + expect(isBackgroundLaunch({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(true) + }) + it('lets native-focus specs opt back into the foreground', () => { expect(isBackgroundLaunch({ ORCA_E2E_HEADFUL: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false) expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false) @@ -39,10 +43,17 @@ describe('isBackgroundLaunch', () => { }) describe('isWindowlessLaunch', () => { - it('is headless-only; a headful run still paints', () => { + it('keeps explicit background launches hidden while headful E2E can paint', () => { expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1' })).toBe(true) expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_HEADFUL: '1' })).toBe(false) - expect(isWindowlessLaunch({ ORCA_BACKGROUND_LAUNCH: '1' })).toBe(false) + expect(isWindowlessLaunch({ ORCA_BACKGROUND_LAUNCH: '1' })).toBe(true) + expect( + isWindowlessLaunch({ + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_E2E_HEADFUL: '1', + ORCA_E2E_FOREGROUND: '1' + }) + ).toBe(true) }) }) @@ -54,9 +65,16 @@ describe('showWindowWithoutStealingFocus', () => { expect(window.showInactive).not.toHaveBeenCalled() }) - it('shows a background window without activating it', () => { + it('never reveals an explicitly background window', () => { const window = makeWindow() showWindowWithoutStealingFocus(window, { ORCA_BACKGROUND_LAUNCH: '1' }) + expect(window.showInactive).not.toHaveBeenCalled() + expect(window.show).not.toHaveBeenCalled() + }) + + it('still reveals explicitly headful E2E without activation', () => { + const window = makeWindow() + showWindowWithoutStealingFocus(window, { ORCA_E2E_HEADFUL: '1' }) expect(window.showInactive).toHaveBeenCalledOnce() expect(window.show).not.toHaveBeenCalled() }) @@ -83,18 +101,21 @@ describe('applyBackgroundActivationPolicy', () => { } } - it('drops the macOS Dock tile and menu bar for headless runs', () => { - const app = makeApp() - expect( - applyBackgroundActivationPolicy({ - app, - env: { ORCA_E2E_HEADLESS: '1' }, - platform: 'darwin' - }) - ).toBe(true) - expect(app.dock.hide).toHaveBeenCalledOnce() - expect(app.setActivationPolicy).toHaveBeenCalledWith('accessory') - }) + it.each(['ORCA_E2E_HEADLESS', 'ORCA_BACKGROUND_LAUNCH'])( + 'drops the macOS Dock tile and menu bar for %s', + (flag) => { + const app = makeApp() + expect( + applyBackgroundActivationPolicy({ + app, + env: { [flag]: '1' }, + platform: 'darwin' + }) + ).toBe(true) + expect(app.dock.hide).toHaveBeenCalledOnce() + expect(app.setActivationPolicy).toHaveBeenCalledWith('accessory') + } + ) it('leaves a headful or user launch with its normal Dock presence', () => { const headful = makeApp() diff --git a/src/main/window/foreground-activation-policy.ts b/src/main/window/foreground-activation-policy.ts index c2ee6b19e73..5d51f50e487 100644 --- a/src/main/window/foreground-activation-policy.ts +++ b/src/main/window/foreground-activation-policy.ts @@ -5,8 +5,8 @@ import { app as electronApp, type BrowserWindow } from 'electron' * validation). These runs may use the machine, but must never take the OS * foreground away from whatever the developer is doing. * - * ORCA_BACKGROUND_LAUNCH=1 opts a normal launch in; ORCA_E2E_FOREGROUND=1 opts - * back out for the few specs whose subject *is* native focus (IME, key events). + * ORCA_BACKGROUND_LAUNCH=1 keeps automation off screen. Native-focus specs + * can use ORCA_E2E_FOREGROUND=1 only without an explicit background request. */ type ActivationPolicyApp = { @@ -19,19 +19,21 @@ type PolicyEnv = Readonly> /** True when this process must not steal focus, raise windows, or activate the app. */ export function isBackgroundLaunch(env: PolicyEnv = process.env): boolean { + if (env.ORCA_BACKGROUND_LAUNCH === '1') { + return true + } if (env.ORCA_E2E_FOREGROUND === '1') { return false } - return ( - env.ORCA_BACKGROUND_LAUNCH === '1' || - env.ORCA_E2E_HEADLESS === '1' || - env.ORCA_E2E_HEADFUL === '1' - ) + return env.ORCA_E2E_HEADLESS === '1' || env.ORCA_E2E_HEADFUL === '1' } -/** True when no window should reach the screen at all (headless E2E; Playwright drives via CDP). */ +/** True when no window should reach the screen at all (background or headless E2E; Playwright drives via CDP). */ export function isWindowlessLaunch(env: PolicyEnv = process.env): boolean { - return isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1' + return ( + env.ORCA_BACKGROUND_LAUNCH === '1' || + (isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1') + ) } /** @@ -63,7 +65,7 @@ export function applyBackgroundActivationPolicy( /** * Reveal a window without taking the foreground: hidden entirely when windowless, - * `showInactive()` (visible, not raised over the active app) in background launches. + * `showInactive()` for explicitly headful E2E runs. */ export function showWindowWithoutStealingFocus( window: BrowserWindow, diff --git a/src/main/windows-pty-root-identity.ts b/src/main/windows-pty-root-identity.ts index c99224cb72a..28c632682d8 100644 --- a/src/main/windows-pty-root-identity.ts +++ b/src/main/windows-pty-root-identity.ts @@ -1,4 +1,4 @@ -import { queryWindowsProcessRowsFresh } from './providers/windows-foreground-process-rows' +import { queryWindowsProcessLinksFresh } from './providers/windows-foreground-process-rows' import { readOrcaChromiumProcessPids } from './orca-chromium-process-pids' /** @@ -138,7 +138,7 @@ export async function verifyWindowsTreeKillTarget( return 'unknown' } const rows = await readLinksBeforeDeadline( - deps.readRows ?? queryWindowsProcessRowsFresh, + deps.readRows ?? queryWindowsProcessLinksFresh, deps.timeoutMs ?? WINDOWS_ROOT_IDENTITY_TIMEOUT_MS ) if (!rows) { diff --git a/src/main/windows/windows-command-line-recovery-health.test.ts b/src/main/windows/windows-command-line-recovery-health.test.ts new file mode 100644 index 00000000000..3791acf6c93 --- /dev/null +++ b/src/main/windows/windows-command-line-recovery-health.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + reportWindowsCommandLineRecoveryHealth, + resetWindowsCommandLineRecoveryHealthForTests +} from './windows-command-line-recovery-health' + +describe('windows command line recovery health', () => { + let warn: ReturnType + + beforeEach(() => { + resetWindowsCommandLineRecoveryHealthForTests() + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + + const selfRow = (commandLine: string): { pid: number; commandLine: string } => ({ + pid: process.pid, + commandLine + }) + + it('warns when the querying process has no command line of its own', () => { + // We can always open ourselves with PROCESS_QUERY_LIMITED_INFORMATION, so + // an empty self command line means the query is refused host-wide. + reportWindowsCommandLineRecoveryHealth([selfRow(''), { pid: 4, commandLine: '' }]) + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain('ProcessCommandLineInformation') + expect(warn.mock.calls[0][1]).toEqual({ processes: 2, withCommandLine: 0 }) + }) + + it('warns once per session, not once per scan', () => { + for (let i = 0; i < 5; i++) { + reportWindowsCommandLineRecoveryHealth([selfRow('')]) + } + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('stays quiet when only other processes denied a handle', () => { + // Roughly a quarter of a real table denies access; that is not a fault. + const denied = Array.from({ length: 40 }, (_, index) => ({ + pid: index + 1, + commandLine: '' + })) + reportWindowsCommandLineRecoveryHealth([selfRow('node.exe --run'), ...denied]) + expect(warn).not.toHaveBeenCalled() + }) + + it('stays quiet when our own row is absent, which the caller rejects separately', () => { + reportWindowsCommandLineRecoveryHealth([{ pid: process.pid + 1, commandLine: '' }]) + expect(warn).not.toHaveBeenCalled() + }) + + it('treats a missing commandLine field the same as an empty one', () => { + reportWindowsCommandLineRecoveryHealth([{ pid: process.pid }]) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/windows/windows-command-line-recovery-health.ts b/src/main/windows/windows-command-line-recovery-health.ts new file mode 100644 index 00000000000..173e8f5a969 --- /dev/null +++ b/src/main/windows/windows-command-line-recovery-health.ts @@ -0,0 +1,47 @@ +/** + * One warning, once per session, when command-line recovery has stopped working. + * + * The reader has no PEB fallback by design: falling back was a total-defeat + * vector, because any single anomalous NTSTATUS reinstated address-space reads + * for the life of the process. The cost of removing it is a cliff -- if + * `NtQueryInformationProcess(ProcessCommandLineInformation)` is refused, every + * command line comes back empty and agent identity matching silently degrades + * to image names, while the addon still loads and still enumerates, so every + * health check stays green. A cliff nobody can see is the failure mode this + * area keeps producing, so it gets a signal. + * + * The querying process is the unambiguous probe. A process can always open + * itself with `PROCESS_QUERY_LIMITED_INFORMATION`, so its own command line + * coming back empty means the query is refused host-wide -- not that some + * target denied a handle, which is normal for roughly a quarter of the table. + * That is why this keys on our own row rather than a fraction: no threshold to + * tune, and no false positive on a hardened box where most processes deny. + */ +type CommandLineRow = { pid: number; commandLine?: string } + +let warned = false + +export function reportWindowsCommandLineRecoveryHealth(rows: CommandLineRow[]): void { + if (warned) { + return + } + const self = rows.find((row) => row.pid === process.pid) + // No self row is a different failure, and the caller's own guard rejects it. + if (!self || (self.commandLine ?? '') !== '') { + return + } + warned = true + const recovered = rows.filter((row) => (row.commandLine ?? '') !== '').length + console.warn( + '[windows-process-table] command-line recovery is refused on this host: the querying ' + + 'process has no command line of its own, so NtQueryInformationProcess' + + '(ProcessCommandLineInformation) is failing for every process. Agent identity matching ' + + 'falls back to image names. A hooked ntdll that does not know class 60 is the usual cause.', + { processes: rows.length, withCommandLine: recovered } + ) +} + +/** Test-only: the warning is once per session, so cases must not inherit it. */ +export function resetWindowsCommandLineRecoveryHealthForTests(): void { + warned = false +} diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts index 213f157f63b..b8d654ce238 100644 --- a/src/main/windows/windows-process-table-cim-scan.ts +++ b/src/main/windows/windows-process-table-cim-scan.ts @@ -75,6 +75,8 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[] return [] } const name = fieldAsString(row.Name) + // No working set: Win32_Process reports one, but nothing reads memory off + // this table and asking widens an already costly scan. return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }] }) } diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 360dae4ec14..f4fd514d319 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { __setWindowsProcessTableCimScanForTests, @@ -5,33 +8,141 @@ import { __setWindowsProcessTreeRequireForTests, isWindowsProcessTableAvailable, isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTable, + readWindowsProcessIdentityTableFresh, readWindowsProcessTable, readWindowsProcessTableFresh, - resetWindowsProcessTableForTests + resetWindowsProcessTableForTests, + type WindowsProcessIdentityRow, + type WindowsProcessRow } from './windows-process-table' +import { resetWindowsCommandLineRecoveryHealthForTests } from './windows-command-line-recovery-health' + +/** None | CreationTime, and CommandLine on top of it. Memory (1) is never asked for. */ +const IDENTITY_FLAGS = 4 +const DETAILED_FLAGS = 6 const getAllProcesses = vi.fn() // A real snapshot always contains the querying process; the reader rejects a // table without it, because that is what a blocked CreateToolhelp32Snapshot -// returns -- an empty list rather than an error. -const SELF = { pid: process.pid, ppid: 0, name: 'vitest.exe' } -const NATIVE = [ +// returns -- an empty list rather than an error. It also always carries our own +// command line, since a process can always open itself -- an empty one there is +// the host-wide-refusal signal, not a fixture detail. +type NativeRow = { + pid: number + ppid: number + name: string + commandLine?: string + creationTimeMs?: number +} + +const SELF: NativeRow = { + pid: process.pid, + ppid: 0, + name: 'vitest.exe', + commandLine: 'vitest.exe --run' +} +const NATIVE: NativeRow[] = [ SELF, { pid: 100, ppid: 4, name: 'orca.exe', commandLine: '"C:/a b/orca.exe" --x', - memory: 4096, creationTimeMs: 1_700_000_000_000 } ] +/** + * The vendored wrapper, faithfully: one `requestInProgress` latch over a shared + * callback queue, resolved asynchronously. A second caller that arrives while a + * request is in flight has its `flags` DISCARDED and is served the first + * caller's rows -- the defect this module's read gate has to exclude. A + * synchronous mock cannot express it, because nothing ever overlaps. + */ +let coalescingCalls: { flags: number }[] = [] +let maxConcurrentNativeCalls = 0 + +function coalescingModule(): { + ProcessDataFlag: { None: number; Memory: number; CommandLine: number; CreationTime: number } + getAllProcesses: (cb: (rows: NativeRow[] | undefined) => void, flags?: number) => void +} { + let requestInProgress = false + const queue: ((rows: NativeRow[]) => void)[] = [] + return { + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: (cb, flags) => { + queue.push(cb) + if (requestInProgress) { + return + } + requestInProgress = true + coalescingCalls.push({ flags: flags ?? 0 }) + // The rows the addon would produce for exactly these flags. Each field is + // gated on its OWN bit: reusing the CommandLine bit for both would strip + // creationTimeMs from an identity read that did request CreationTime, and + // no case could then tell a served-someone-else's-rows bug from a + // correctly-shaped cheap read. + const requested = flags ?? 0 + const rows: NativeRow[] = NATIVE.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + name: row.name, + ...(requested & 2 && row.commandLine !== undefined ? { commandLine: row.commandLine } : {}), + ...(requested & 4 && row.creationTimeMs !== undefined + ? { creationTimeMs: row.creationTimeMs } + : {}) + })) + setTimeout(() => { + while (queue.length) { + queue.splice(0).forEach((callback) => callback(rows)) + } + requestInProgress = false + }, 0) + } + } +} + +/** One instance for the whole test: the latch it models is module-global. */ +function installCoalescingModule(): void { + const native = coalescingModule() + __setWindowsProcessTreeLoaderForTests(() => native) +} + +/** + * The relay's bare addon: `adaptAddon` over `getProcessList`, with no queue of + * any kind. Two simultaneous `CreateToolhelp32Snapshot` calls are the crash the + * vendor's queue exists to prevent, so here re-entry is observable rather than + * silently absorbed. + * + * Concurrency has to be measured against this and never against the coalescing + * mock, whose own latch means it can only ever report one call in flight -- an + * assertion that holds whether or not this module excludes anything. + */ +function installBareAddonModule(): void { + let inFlight = 0 + const native = { + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: (cb: (rows: NativeRow[] | undefined) => void, flags?: number) => { + coalescingCalls.push({ flags: flags ?? 0 }) + inFlight += 1 + maxConcurrentNativeCalls = Math.max(maxConcurrentNativeCalls, inFlight) + setTimeout(() => { + inFlight -= 1 + cb(NATIVE) + }, 0) + } + } + __setWindowsProcessTreeLoaderForTests(() => native) +} + describe('windows process table', () => { let platform: PropertyDescriptor | undefined beforeEach(() => { + coalescingCalls = [] + maxConcurrentNativeCalls = 0 getAllProcesses.mockReset() getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE)) platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -52,7 +163,7 @@ describe('windows process table', () => { it('maps native rows, defaulting an unreadable command line to empty', async () => { const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: 'vitest.exe --run' }, { pid: 100, ppid: 4, @@ -63,13 +174,157 @@ describe('windows process table', () => { ]) }) - it('requests the command line and creation time, never memory', async () => { + it('asks for the command line but never for memory', async () => { + // Memory costs a second OpenProcess(PROCESS_VM_READ) per process and no + // caller reads a working set off this table. await readWindowsProcessTableFresh() - // CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the - // addon opens a second PROCESS_VM_READ handle per process to serve it and - // nothing reads a working set off this table. - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6) - expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0) + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(DETAILED_FLAGS) + }) + + it('reads the identity table with no per-process handle flag at all', async () => { + await readWindowsProcessIdentityTableFresh() + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(IDENTITY_FLAGS) + }) + + it('drops the command line from identity rows rather than leaving it empty', async () => { + const rows = await readWindowsProcessIdentityTableFresh() + expect(rows).toEqual([ + { pid: process.pid, ppid: 0, name: 'vitest.exe' }, + { pid: 100, ppid: 4, name: 'orca.exe', creationTimeMs: 1_700_000_000_000 } + ]) + expect(rows.every((row) => !('command' in row))).toBe(true) + }) + + it('collapses a 32-wide burst into one scan per flag set', async () => { + installCoalescingModule() + const [identity, detailed] = await Promise.all([ + Promise.all(Array.from({ length: 16 }, () => readWindowsProcessIdentityTable())), + Promise.all(Array.from({ length: 16 }, () => readWindowsProcessTable())) + ]) + expect(coalescingCalls.map((call) => call.flags).sort()).toEqual([ + IDENTITY_FLAGS, + DETAILED_FLAGS + ]) + expect(identity).toHaveLength(16) + expect(detailed).toHaveLength(16) + }) + + // The npm wrapper coalesces rather than queues: a second concurrent caller's + // flags are discarded and it is served the first caller's rows. Overlapping an + // identity read with a detailed one therefore used to hand agent recognition a + // table with every command line empty. + async function expectEachViewGotItsOwnFlags( + identity: Promise, + detailed: Promise + ): Promise { + const [identityRows, detailedRows] = await Promise.all([identity, detailed]) + expect(detailedRows.some((row) => row.command === '"C:/a b/orca.exe" --x')).toBe(true) + expect(identityRows.every((row) => !('command' in row))).toBe(true) + // Both sets carry what their own flags asked for. Not redundant with the + // flags check below: that one catches a read served the OTHER set's rows, + // this one catches field shaping -- identity dropping CreationTime from its + // flags, or toIdentityRow failing to forward it. Neither sees the other's + // failure, so keep both. + expect(identityRows.map((row) => row.creationTimeMs)).toEqual([undefined, 1_700_000_000_000]) + expect(detailedRows.map((row) => row.creationTimeMs)).toEqual([undefined, 1_700_000_000_000]) + // Two calls, each with its own flags. Concurrency is asserted separately, + // against the bare addon: this mock's own latch means it could never report + // more than one call in flight, whatever this module did. + expect(coalescingCalls.map((call) => call.flags).sort()).toEqual([ + IDENTITY_FLAGS, + DETAILED_FLAGS + ]) + } + + it('gives each flag set its own data when the identity read is issued first', async () => { + installCoalescingModule() + const identity = readWindowsProcessIdentityTableFresh() + const detailed = readWindowsProcessTableFresh() + await expectEachViewGotItsOwnFlags(identity, detailed) + }) + + it('gives each flag set its own data when the detailed read is issued first', async () => { + installCoalescingModule() + const detailed = readWindowsProcessTableFresh() + const identity = readWindowsProcessIdentityTableFresh() + await expectEachViewGotItsOwnFlags(identity, detailed) + }) + + /** Microtasks only: the mocks call back on a timer, so nothing completes. */ + async function parkPendingReadsOnTheGate(): Promise { + for (let tick = 0; tick < 20; tick += 1) { + await Promise.resolve() + } + } + + it('never re-enters the bare relay addon when both flag sets overlap', async () => { + installBareAddonModule() + const detailed = readWindowsProcessTableFresh() + const identity = readWindowsProcessIdentityTableFresh() + await Promise.all([detailed, identity]) + expect(coalescingCalls.map((call) => call.flags).sort()).toEqual([ + IDENTITY_FLAGS, + DETAILED_FLAGS + ]) + expect(maxConcurrentNativeCalls).toBe(1) + }) + + it('keeps one read in flight across a test reset', async () => { + // Replacing the gate rather than chaining onto it lets a waiter still + // holding the old chain run beside a read queued on the new one. Reachable + // only from the test hooks -- which is the problem: it hands a suite two + // concurrent calls into its own mock, the exact condition the cases above + // exist to detect. + installBareAddonModule() + const inFlight = readWindowsProcessTableFresh() + const waiter = readWindowsProcessIdentityTableFresh() + await parkPendingReadsOnTheGate() + resetWindowsProcessTableForTests() + const afterReset = readWindowsProcessTableFresh() + + await Promise.allSettled([inFlight, waiter, afterReset]) + expect(maxConcurrentNativeCalls).toBe(1) + }) + + it('does not serve one flag set from the other cache', async () => { + await readWindowsProcessTable() + await readWindowsProcessIdentityTable() + expect(getAllProcesses).toHaveBeenCalledTimes(2) + }) + + it('rejects an empty identity snapshot rather than reporting an idle machine', async () => { + getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb([])) + resetWindowsProcessTableForTests() + await expect(readWindowsProcessIdentityTableFresh()).rejects.toThrow(/unreadable/) + }) + + it('applies the deadline to the identity read too', async () => { + vi.useFakeTimers() + getAllProcesses.mockImplementation(() => {}) + resetWindowsProcessTableForTests() + const pending = readWindowsProcessIdentityTableFresh() + const assertion = expect(pending).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(3_000) + await assertion + vi.useRealTimers() + }) + + it('shares the wedge gate across flag sets, because they share one addon', async () => { + // One wedged read latches the vendored `requestInProgress` and pins the one + // libuv slot whichever flags asked for it, so a per-flag-set gate would let + // the other reader keep parking callbacks behind it. + vi.useFakeTimers() + getAllProcesses.mockImplementation(() => {}) + resetWindowsProcessTableForTests() + const wedge = readWindowsProcessIdentityTableFresh() + const wedgeAssertion = expect(wedge).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(3_000) + await wedgeAssertion + + await expect(readWindowsProcessTableFresh()).rejects.toThrow(/wedged/) + await expect(readWindowsProcessIdentityTableFresh()).rejects.toThrow(/wedged/) + expect(getAllProcesses).toHaveBeenCalledTimes(1) + vi.useRealTimers() }) it('only advertises PID-safe ownership when the native creation-time field exists', () => { @@ -167,6 +422,27 @@ describe('PowerShell fallback when the native binding is absent', () => { expect(cimScan).toHaveBeenCalledTimes(1) }) + it('serves the identity view from the one scan a relay can afford', async () => { + // With no binding there is only one scan to run and it costs ~1.4s and a + // powershell.exe, so the cheap view must ride it rather than fork a second. + __setWindowsProcessTreeLoaderForTests(() => null) + // Projected, not merely widened: an identity row carries no command line on + // any host, so nothing can come to depend on the fallback happening to have + // one. + await expect(readWindowsProcessIdentityTableFresh()).resolves.toEqual([ + { pid: process.pid, ppid: 0, name: 'node.exe' }, + { pid: 200, ppid: process.pid, name: 'claude.exe' } + ]) + await readWindowsProcessTable() + expect(cimScan).toHaveBeenCalledTimes(1) + }) + + it('rejects an identity read that omits our own pid', async () => { + __setWindowsProcessTreeLoaderForTests(() => null) + cimScan.mockResolvedValue([{ pid: 200, ppid: 4, name: 'claude.exe', command: 'claude' }]) + await expect(readWindowsProcessIdentityTableFresh()).rejects.toThrow(/unreadable/) + }) + it('does not engage when the native binding is present', async () => { const getAllProcesses = vi.fn() getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => cb(NATIVE)) @@ -360,6 +636,7 @@ describe('resolving the native reader', () => { let platform: PropertyDescriptor | undefined const PACKAGE_SPECIFIER = '@vscode/windows-process-tree' const ADDON_SPECIFIER = './windows-process-tree.node' + const stagedAddonDirs: string[] = [] beforeEach(() => { platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -369,6 +646,9 @@ describe('resolving the native reader', () => { afterEach(() => { __setWindowsProcessTreeRequireForTests() __setWindowsProcessTableCimScanForTests() + for (const dir of stagedAddonDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } if (platform) { Object.defineProperty(process, 'platform', platform) } @@ -403,7 +683,7 @@ describe('resolving the native reader', () => { }) const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: 'vitest.exe --run' }, { pid: 100, ppid: 4, @@ -415,7 +695,7 @@ describe('resolving the native reader', () => { expect(isWindowsProcessTableAvailable()).toBe(true) }) - it('asks the addon for the command line but not memory, as the package path does', async () => { + it('asks the addon for the command line, as the package path does', async () => { const addon = addonReturning(NATIVE) __setWindowsProcessTreeRequireForTests((specifier: string) => { if (specifier === ADDON_SPECIFIER) { @@ -424,12 +704,26 @@ describe('resolving the native reader', () => { throw new Error('MODULE_NOT_FOUND') }) await readWindowsProcessTableFresh() - // CommandLine only: a bare snapshot would silently drop the command line - // every agent-recognition caller matches on first, and the relay addon - // exposes no CreationTime bit to add. + // CommandLine alone: a bare snapshot would silently drop the command line + // every agent-recognition caller matches on first, and Memory would add a + // second per-process handle nothing reads. expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2) }) + it('asks the addon for nothing per-process on the identity path', async () => { + const addon = addonReturning(NATIVE) + __setWindowsProcessTreeRequireForTests((specifier: string) => { + if (specifier === ADDON_SPECIFIER) { + return addon + } + throw new Error('MODULE_NOT_FOUND') + }) + await readWindowsProcessIdentityTableFresh() + // The relay addon exposes no CreationTime bit, so this is a bare Toolhelp32 + // walk: zero OpenProcess calls. + expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 0) + }) + it('reaches the CIM scan when neither the package nor the addon is present', async () => { const cimScan = vi .fn() @@ -464,6 +758,64 @@ describe('resolving the native reader', () => { expect(cimScan).toHaveBeenCalledTimes(1) }) + // A relay bundle and the addon staged beside it redeploy independently, so a + // host that never took a new bundle can still be loading the published + // prebuilt -- which binds fine and then walks every process's address space. + // The relay build asserts the symbol is absent; nothing did at load. + function withStagedAddonBinary( + bytes: string, + addon: unknown + ): ((specifier: string) => unknown) & { resolve: (specifier: string) => string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-relay-addon-')) + const addonPath = join(dir, 'windows-process-tree.node') + writeFileSync(addonPath, bytes) + stagedAddonDirs.push(dir) + const resolve = (specifier: string): unknown => { + if (specifier === ADDON_SPECIFIER) { + return addon + } + throw new Error('MODULE_NOT_FOUND') + } + resolve.resolve = (specifier: string): string => { + if (specifier === ADDON_SPECIFIER) { + return addonPath + } + throw new Error('MODULE_NOT_FOUND') + } + return resolve + } + + it('refuses a staged relay addon still built from unpatched source', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cimScan = vi + .fn() + .mockResolvedValue([ + { pid: process.pid, ppid: 0, name: 'node.exe', command: 'node relay.js' } + ]) + __setWindowsProcessTableCimScanForTests(cimScan) + const addon = addonReturning(NATIVE) + __setWindowsProcessTreeRequireForTests( + withStagedAddonBinary('MZ\0KERNEL32.dll\0ReadProcessMemory\0', addon) + ) + + await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(1) + expect(addon.getProcessList).not.toHaveBeenCalled() + expect(cimScan).toHaveBeenCalledTimes(1) + expect(isWindowsProcessTableAvailable()).toBe(false) + expect(warn.mock.calls[0]?.[0]).toContain('ReadProcessMemory') + warn.mockRestore() + }) + + it('binds a staged relay addon whose binary carries no such import', async () => { + const addon = addonReturning(NATIVE) + __setWindowsProcessTreeRequireForTests( + withStagedAddonBinary('MZ\0ntdll.dll\0NtQueryInformationProcess\0', addon) + ) + + await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(2) + expect(addon.getProcessList).toHaveBeenCalledTimes(1) + }) + it('never probes either specifier off Windows', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) const resolve = vi.fn() @@ -472,3 +824,56 @@ describe('resolving the native reader', () => { expect(resolve).not.toHaveBeenCalled() }) }) + +// The cliff the removed PEB fallback leaves behind: a hooked ntdll that refuses +// class 60 empties every command line, and the addon still loads and still +// enumerates, so every health check the app has stays green. +describe('warning when command-line recovery is refused host-wide', () => { + let platform: PropertyDescriptor | undefined + let warn: ReturnType + + beforeEach(() => { + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + resetWindowsCommandLineRecoveryHealthForTests() + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + type NativeRow = { pid: number; ppid: number; name: string; commandLine?: string } + + function loaderReturning(rows: NativeRow[], commandLineFlag: number): void { + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: commandLineFlag, CreationTime: 4 }, + getAllProcesses: (cb: (r: NativeRow[] | undefined) => void) => cb(rows) + })) + } + + it('warns once when our own row comes back with no command line', async () => { + loaderReturning([{ pid: process.pid, ppid: 0, name: 'vitest.exe' }], 2) + await readWindowsProcessTableFresh() + await readWindowsProcessTableFresh() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain('ProcessCommandLineInformation') + }) + + it('stays quiet when our own command line came back', async () => { + loaderReturning(NATIVE, 2) + await readWindowsProcessTableFresh() + expect(warn).not.toHaveBeenCalled() + }) + + it('stays quiet when the read never asked for a command line', async () => { + // A reader that requests identity fields only must not read as a refusal. + loaderReturning([{ pid: process.pid, ppid: 0, name: 'vitest.exe' }], 0) + await readWindowsProcessTableFresh() + expect(warn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 2bd63ccce9c..0a1acd7ae1c 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -1,5 +1,7 @@ +import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot-reader' +import { reportWindowsCommandLineRecoveryHealth } from './windows-command-line-recovery-health' import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan' /** @@ -19,26 +21,41 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan' * A Toolhelp32 snapshot answers the same question in ~16 ms with no child * process at all, so none of those failure modes have anywhere to live. * - * Measured on Windows 11 (1050 processes), p50 / p95: - * pid+ppid+name 15.9 / 17.5 ms - * +memory +commandLine 30.6 / 33.7 ms - * PowerShell CIM 706 / 723 ms + * Two flag sets, because only some callers need a command line, and exactly one + * native read in flight at a time, because the vendored wrapper coalesces + * differing flags -- see docs/reference/windows-process-enumeration.md. * - * Those are the module's published figures for both extra fields together; the - * only flag set this module asks for is `CommandLine` (+ `CreationTime`, free), - * which sits between the two rows and has not been separately measured. + * Measured on Windows 11 (492 processes), p50 / p95: + * identity pid+ppid+name 6.3 / 7.0 ms 0 OpenProcess + * detailed +commandLine 12.3 / 13.4 ms 1 OpenProcess/process + * (retired) +memory +commandLine 13.1 / 14.1 ms 2 OpenProcess/process + * PowerShell CIM 706 / 723 ms + * + * Dropping Memory removed the second per-process handle: it took an + * OpenProcess(...|VM_READ) it never read through. CommandLine's own read is no + * longer a PEB walk either -- the patched addon asks the kernel, so identity is + * now the only flag set that opens nothing at all. + * + * All Toolhelp32 rows assume the optional `windows-process-tree.node` addon. + * The desktop bundles it; no released relay carries it, so on an SSH host the + * CIM row is the operative number and the child process is not avoided at all. */ -export type WindowsProcessRow = { +/** Everything a Toolhelp32 walk alone can answer. */ +export type WindowsProcessIdentityRow = { pid: number ppid: number name: string - /** Full command line. Empty when the process denied a query handle. */ - command: string /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ creationTimeMs?: number } +/** Adds the kernel-supplied command line. Only ask for this if you read it. */ +export type WindowsProcessRow = WindowsProcessIdentityRow & { + /** Full command line. Empty when the process denied a query handle. */ + command: string +} + type NativeProcessInfo = { pid: number ppid: number @@ -61,19 +78,26 @@ type WindowsProcessTreeModule = { const requireFromMain = createRequire(__filename) +/** `resolve` is optional so a test can inject a bare function for the require alone. */ +type NativeRequire = ((specifier: string) => unknown) & { + resolve?: (specifier: string) => string +} + // Why injectable: `createRequire` bypasses the module mocker, and the two // resolution steps below are the exact thing #15749 shipped untested -- the // relay suites replaced the loader wholesale, so nothing exercised the require. -let requireNative: (specifier: string) => unknown = requireFromMain +let requireNative: NativeRequire = requireFromMain /** * The bare addon a relay host receives, with no npm package around it. * * The published package's `lib/index.js` adds only a queue over this call, and * that queue is the wedge this module already defends against: it latches a - * module-global `requestInProgress` with no try/catch. We hold our own - * single-flight and deadline, so binding straight to the addon drops the - * duplicate queue rather than nesting inside it. + * module-global `requestInProgress` with no try/catch. `nativeReadGate` holds + * the mutual exclusion instead -- and must, because this addon has no queue of + * its own and two simultaneous `CreateToolhelp32Snapshot` calls are the crash + * the vendor's queue exists to prevent. With one native call ever outstanding, + * binding straight to the addon drops a duplicate rather than losing a guard. */ type WindowsProcessTreeAddon = { getProcessList: ( @@ -84,13 +108,50 @@ type WindowsProcessTreeAddon = { /** * Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1) - * is listed for completeness and is deliberately never set — see `flags` below. + * is listed for completeness and is deliberately never set — see the projections + * below. */ const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const /** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */ const RELAY_ADDON_FILENAME = './windows-process-tree.node' +/** The import whose absence tells the patched binary from the published prebuilt. */ +const FLAGGED_ADDON_IMPORT = 'ReadProcessMemory' + +/** + * Refuse a staged relay addon built from unpatched source. + * + * The build asserts this on the artifact it produces, but a relay bundle and the + * addon beside it are redeployed independently: a host that has not taken a new + * bundle keeps whatever `.node` is already there, and the published prebuilt is + * node-addon-api, so it binds cleanly and then opens every process with + * `PROCESS_VM_READ` to walk its PEB -- the primitive MDE scores as credential + * dumping. Nothing checked that at load until here. + * + * Same predicate as `inspectWindowsProcessTreeAddon` in + * `config/scripts/windows-process-tree-gyp-rebuild.mjs`, which cannot be + * imported here: it is install-time tooling that pulls in node-gyp and + * `child_process`, and this module is bundled into the app and the relay. + * + * Falling back to the CIM scan is the correct loss: it is slower, and it is not + * the thing an EDR quarantines the host for. + */ +function stagedRelayAddonIsUnpatched(): boolean { + // No resolver means an injected test double, so there is no file to inspect. + // Production always has one, and a require that just succeeded proves the + // path is readable -- "cannot tell" here is never a real deployment. + const addonPath = requireNative.resolve?.(RELAY_ADDON_FILENAME) + if (!addonPath) { + return false + } + try { + return readFileSync(addonPath).includes(FLAGGED_ADDON_IMPORT) + } catch { + return false + } +} + let cachedModule: WindowsProcessTreeModule | null | undefined let moduleLoader: () => WindowsProcessTreeModule | null = loadWindowsProcessTree let cimScan: () => Promise = readWindowsProcessRowsWithCim @@ -135,8 +196,22 @@ function loadWindowsProcessTree(): WindowsProcessTreeModule | null { // Why check the shape: a truncated upload or an addon built for another // arch can load and still not answer. Binding to it would then reject every // read forever, where falling through reaches a scan that works. - cachedModule = - typeof addon?.getProcessList === 'function' ? adaptAddon(addon) : /* v8 ignore next */ null + if (typeof addon?.getProcessList !== 'function') { + /* v8 ignore next 2 */ + cachedModule = null + return cachedModule + } + if (stagedRelayAddonIsUnpatched()) { + console.warn( + `[windows-process-table] the addon staged beside the relay bundle still imports ` + + `${FLAGGED_ADDON_IMPORT}, so it was built from unpatched source and reads every ` + + 'process address space. Refusing it and falling back to the CIM scan; redeploy the ' + + 'relay so the staged addon is rebuilt.' + ) + cachedModule = null + return cachedModule + } + cachedModule = adaptAddon(addon) } catch { cachedModule = null } @@ -160,26 +235,122 @@ const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000 * Reads that missed their deadline and have not called back yet. * Refusing re-entry bounds both vendored callbacks and relay addon workers to * one; read ids keep a late callback from clearing a newer wedge. + * + * One gate for both flag sets, not one each: they call the same addon, so a + * wedged read latches the one `requestInProgress` and pins the one libuv slot + * whichever flags asked for it. Retention stays at exactly one callback rather + * than one per reader because `nativeReadGate` below already admits only one + * native call at a time; read ids are module-global and monotonic, so a late + * callback can only clear its own wedge. */ const unreturnedReads = new Set() let readSequence = 0 let nativeReaderEpoch = 0 +/** + * Admits one native read at a time, across both flag sets. Nothing else does. + * + * The npm wrapper coalesces rather than queues: `getRawProcessList` pushes the + * callback onto one list and only calls the addon when no request is in + * progress, so a second concurrent caller's `flags` are DISCARDED and it is + * handed the first caller's rows. An identity read racing a detailed read + * therefore returns a table with every command line EMPTY, which agent + * recognition reads as "no agent" -- silently, and only under concurrency. + * Measured against the real addon: identity issued first, both callers got the + * same array, 0 of 541 rows with a command line. + * + * Nothing above stops that. Each cache single-flights only within itself + * (`inFlight` is a closure per reader) and the wedge set latches only after a + * read misses its 3s deadline, so through the healthy ~12ms of a scan neither + * excludes the other. Overlap is the normal state, not an edge case: panes poll + * detailed every 750ms while a teardown takes identity snapshots. + * + * It also has to be here for the relay's bare addon, which has no queue at all: + * 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 instead. + */ +let nativeReadGate: Promise = Promise.resolve() + function resetNativeReaderState(): void { nativeReaderEpoch += 1 unreturnedReads.clear() + // Chain, never replace. Dropping the old chain lets a waiter still holding it + // run against a read queued on the new one -- two concurrent calls into one + // mock addon, which is precisely the coalescing these suites exist to catch. + // Every link settles within the deadline, so the wait this costs is bounded. + nativeReadGate = nativeReadGate.then(ignoreSettlement, ignoreSettlement) } -function readNativeRows(): Promise { +/** A flag set and the row shape it can honestly produce. */ +type ProcessRowProjection = { + flags: (native: WindowsProcessTreeModule) => number + fromNative: (row: NativeProcessInfo) => Row + /** + * The no-binding scan, on the one flag set it can serve. Absent on the other, + * because a relay must never run two `Get-CimInstance` scans at ~1.4s each -- + * `readWindowsProcessIdentityTable` projects the detailed snapshot instead. + */ + cimFallback?: () => Promise +} + +function toIdentityRow(row: { + pid: number + ppid: number + name: string + creationTimeMs?: number +}): WindowsProcessIdentityRow { + return { + pid: row.pid, + ppid: row.ppid, + name: row.name, + ...(typeof row.creationTimeMs === 'number' ? { creationTimeMs: row.creationTimeMs } : {}) + } +} + +/** + * Toolhelp32 and nothing else: no `OpenProcess` per process, so this read has + * none of the shape an EDR scores as walking another process's memory. + */ +const IDENTITY_PROJECTION: ProcessRowProjection = { + flags: (native) => native.ProcessDataFlag.None | (native.ProcessDataFlag.CreationTime ?? 0), + fromNative: toIdentityRow +} + +/** + * Adds, per process, one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` and an + * `NtQueryInformationProcess(ProcessCommandLineInformation)` -- which is what + * agent recognition and port attribution match on. `Memory` is deliberately + * absent: it took a second handle carrying `PROCESS_VM_READ` and then never read + * through it, and no caller reads a working set off this table (the Resource + * Manager runs its own sweep, and the native field wraps above 4 GB anyway). + */ +const DETAILED_PROJECTION: ProcessRowProjection = { + flags: (native) => IDENTITY_PROJECTION.flags(native) | native.ProcessDataFlag.CommandLine, + fromNative: (row) => ({ ...toIdentityRow(row), command: row.commandLine ?? '' }), + cimFallback: readCimRows +} + +function ignoreSettlement(): void {} + +function readNativeRows(projection: ProcessRowProjection): Promise { + const attempt = nativeReadGate.then(() => readOneSnapshot(projection)) + nativeReadGate = attempt.then(ignoreSettlement, ignoreSettlement) + return attempt +} + +function readOneSnapshot(projection: ProcessRowProjection): Promise { const native = moduleLoader() if (!native) { - if (process.platform === 'win32') { + if (process.platform === 'win32' && projection.cimFallback) { // Why only when the module is absent: a binding that loads is the fast // path even when a read fails or wedges, so a failing native reader must // never silently start forking shells at the caller's poll rate. Absence // is the one condition that can never resolve itself — see // docs/reference/windows-process-enumeration.md. - return readCimRows() + return projection.cimFallback() } // Reject rather than resolve empty: an empty table is a claim that nothing // is running, and callers act on that by force-killing or by declaring a @@ -193,16 +364,7 @@ function readNativeRows(): Promise { } const readId = ++readSequence const readerEpoch = nativeReaderEpoch - // Why CommandLine but not Memory: each flag costs one OpenProcess per process - // inside the addon (process.cc), and every caller of this table matches on - // `command`, while nothing reads a working set off it -- the Resource Manager - // runs its own CIM sweep because it needs commit and CPU time in one pass, and - // `process.cc` truncates the working set into a DWORD anyway. Dropping Memory - // halves the per-snapshot handle count; the remaining flags stay in ONE flag - // set because every read shares one snapshot, so a 32-wide teardown collapses - // into a single scan. Splitting the cache per field set would restore exactly - // the fan-out it exists to prevent. - const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0) + const flags = projection.flags(native) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -238,17 +400,11 @@ function readNativeRows(): Promise { reject(new Error('windows process table is unreadable')) return } - resolve( - processes.map((row) => ({ - pid: row.pid, - ppid: row.ppid, - name: row.name, - command: row.commandLine ?? '', - ...(typeof row.creationTimeMs === 'number' - ? { creationTimeMs: row.creationTimeMs } - : {}) - })) - ) + // Only meaningful when a command line was actually asked for. + if ((flags & native.ProcessDataFlag.CommandLine) !== 0) { + reportWindowsCommandLineRecoveryHealth(processes) + } + resolve(processes.map(projection.fromNative)) }, flags) } catch (error) { clearTimeout(deadline) @@ -275,14 +431,38 @@ async function readCimRows(): Promise { // Why still cache: the snapshot is cheap but not free, and a worktree delete // tears down PTYs 32-wide. The shared TTL + single-in-flight reader collapses // that burst into one scan, exactly as the PowerShell path had to. -const snapshotReader = createProcessTableSnapshotReader({ - runPs: readNativeRows, +// +// Why two caches are safe where N would not be: the fan-out this prevents is +// one scan per *caller*, and each reader below still serves every caller that +// wants its flag set, so a 32-wide teardown collapses into one scan per flag +// set. Two is the number of distinct native calls that exist -- a third cache +// would need a third flag set, never a third caller. +const identityReader = createProcessTableSnapshotReader({ + runPs: () => readNativeRows(IDENTITY_PROJECTION), + now: () => Date.now() +}) +const detailedReader = createProcessTableSnapshotReader({ + runPs: () => readNativeRows(DETAILED_PROJECTION), now: () => Date.now() }) -/** Cached snapshot, refreshed on the shared TTL. */ +/** + * With no binding there is only one scan to run and it is the expensive one, so + * the identity view rides the detailed snapshot rather than forking a second + * `powershell.exe` at ~1.4 s a scan. Projected, not merely widened: an identity + * row must not carry a command line on any host. + */ +async function readIdentityRows(fresh: boolean): Promise { + if (moduleLoader() === null) { + const rows = await (fresh ? detailedReader.getFreshSnapshot() : detailedReader.getSnapshot()) + return rows.map(toIdentityRow) + } + return fresh ? identityReader.getFreshSnapshot() : identityReader.getSnapshot() +} + +/** Cached command-line snapshot, refreshed on the shared TTL. */ export function readWindowsProcessTable(): Promise { - return snapshotReader.getSnapshot() + return detailedReader.getSnapshot() } /** @@ -292,7 +472,17 @@ export function readWindowsProcessTable(): Promise { * the very process exit it is being asked about. */ export function readWindowsProcessTableFresh(): Promise { - return snapshotReader.getFreshSnapshot() + return detailedReader.getFreshSnapshot() +} + +/** Cached pid/ppid/name snapshot. Prefer this whenever no command line is read. */ +export function readWindowsProcessIdentityTable(): Promise { + return readIdentityRows(false) +} + +/** The identity snapshot, from a scan that starts after this call. */ +export function readWindowsProcessIdentityTableFresh(): Promise { + return readIdentityRows(true) } /** Whether the native table can be read at all on this host. */ @@ -311,6 +501,11 @@ export function isWindowsProcessStartTimeAvailable(): boolean { return native !== null && typeof native.ProcessDataFlag.CreationTime === 'number' } +function resetSnapshotReaders(): void { + identityReader.reset() + detailedReader.reset() +} + /** * Test-only: substitute the native module. * @@ -324,18 +519,21 @@ export function __setWindowsProcessTreeLoaderForTests( moduleLoader = loader ?? loadWindowsProcessTree cachedModule = undefined resetNativeReaderState() - snapshotReader.reset() + resetSnapshotReaders() } -/** Test-only: substitute the require that resolves the package and the addon. */ -export function __setWindowsProcessTreeRequireForTests( - resolve?: (specifier: string) => unknown -): void { +/** + * Test-only: substitute the require that resolves the package and the addon. + * + * Attach a `resolve` to the injected function to also exercise the staged-addon + * binary check; without one, the loader has no path to inspect. + */ +export function __setWindowsProcessTreeRequireForTests(resolve?: NativeRequire): void { requireNative = resolve ?? requireFromMain moduleLoader = loadWindowsProcessTree cachedModule = undefined resetNativeReaderState() - snapshotReader.reset() + resetSnapshotReaders() } /** Test-only: substitute the no-binding PowerShell scan, which spawns a child. */ @@ -343,12 +541,12 @@ export function __setWindowsProcessTableCimScanForTests( scan?: () => Promise ): void { cimScan = scan ?? readWindowsProcessRowsWithCim - snapshotReader.reset() + resetSnapshotReaders() } -/** Test-only: drop the shared snapshot so suites cannot serve each other's rows. */ +/** Test-only: drop the shared snapshots so suites cannot serve each other's rows. */ export function resetWindowsProcessTableForTests(): void { - snapshotReader.reset() + resetSnapshotReaders() cachedModule = undefined resetNativeReaderState() } diff --git a/src/main/windows/windows-process-tree-command-line-patch.test.ts b/src/main/windows/windows-process-tree-command-line-patch.test.ts new file mode 100644 index 00000000000..1eaa4459c9e --- /dev/null +++ b/src/main/windows/windows-process-tree-command-line-patch.test.ts @@ -0,0 +1,188 @@ +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +/** + * The command-line reader is a patch, not repo source, so its contract is + * asserted against the patch's post-image. MDE scored the addon for + * `OpenProcess(PROCESS_VM_READ)` + `ReadProcessMemory` over the whole process + * table on a timer; these cases exist so a patch refresh cannot quietly restore + * that primitive. + */ +const PATCH_PATH = resolve( + import.meta.dirname, + '../../../config/patches/@vscode__windows-process-tree@0.8.0.patch' +) + +/** Reconstruct a file as the patch leaves it: context plus added lines. */ +function patchedFile(patch: string, path: string): string { + const lines = patch.split('\n') + const start = lines.findIndex((line) => line.startsWith(`diff --git a/${path} `)) + if (start === -1) { + throw new Error(`${path} is not in the patch`) + } + const rest = lines.slice(start + 1) + const end = rest.findIndex((line) => line.startsWith('diff --git ')) + return ( + (end === -1 ? rest : rest.slice(0, end)) + // A context line for an empty source line is a bare space, and unified + // diffs may drop even that, so an empty string is context too. + .filter((line) => line === '' || line.startsWith(' ') || line.startsWith('+')) + .filter((line) => !line.startsWith('+++') && !line.startsWith('@@')) + .map((line) => line.slice(1)) + .join('\n') + ) +} + +const patch = readFileSync(PATCH_PATH, 'utf8') +const commandLineSource = patchedFile(patch, 'src/process_commandline.cc') +const processSource = patchedFile(patch, 'src/process.cc') + +describe('windows-process-tree command line patch', () => { + it('reads the command line through ProcessCommandLineInformation', () => { + // Class 60 is Windows 8.1+; Electron's floor is Windows 10, so every OS + // Orca supports has it. + expect(commandLineSource).toContain('kProcessCommandLineInformation = 60') + expect(commandLineSource).toContain('OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION') + }) + + it('resolves NtQueryInformationProcess dynamically rather than linking it', () => { + expect(commandLineSource).toContain('GetModuleHandleW(L"ntdll.dll")') + expect(commandLineSource).toContain('GetProcAddress(ntdll, "NtQueryInformationProcess")') + }) + + it('probes the buffer size before allocating, and caps it', () => { + // STATUS_INFO_LENGTH_MISMATCH / STATUS_BUFFER_TOO_SMALL carry the size. + expect(commandLineSource).toContain( + 'kStatusInfoLengthMismatch = static_cast(0xC0000004L)' + ) + expect(commandLineSource).toContain( + 'kStatusBufferTooSmall = static_cast(0xC0000023L)' + ) + expect(commandLineSource).toMatch( + /query\(process, kProcessCommandLineInformation, nullptr, 0, &size\)/ + ) + // UNICODE_STRING::Length is a USHORT, so a bogus size must not become a + // bad_alloc that fails the whole scan. + expect(commandLineSource).toContain('size > kMaxCommandLineBytes') + }) + + it('treats the returned UNICODE_STRING as untrusted', () => { + // A hooked ntdll is the environment this reader targets, so an unchecked + // Buffer/Length would be an over-read encoded straight into JS. The bound + // must be buffer.size(), not `size`, which the second query overwrites. + expect(commandLineSource).toContain('const unsigned char* end = begin + buffer.size()') + expect(commandLineSource).toMatch(/chars == nullptr \|\|/) + expect(commandLineSource).toMatch(/command_line->Length > static_cast\(end - chars\)/) + }) + + it('has no PEB fallback and no latch that could reinstate one', () => { + // The fallback used to be reachable from any single anomalous NTSTATUS, + // which on an EDR-hooked ntdll is the realistic case -- one stray status + // would have silently restored the primitive for the process lifetime. + expect(commandLineSource).not.toContain('ReadCommandLineFromPeb') + expect(commandLineSource).not.toContain('PROCESS_BASIC_INFORMATION') + expect(commandLineSource).not.toContain('InterlockedExchange') + expect(commandLineSource).not.toMatch(/ReadProcessMemory\(/) + }) + + it('acquires PROCESS_VM_READ nowhere in the addon', () => { + for (const source of [commandLineSource, processSource]) { + expect(source).not.toMatch(/OpenProcess\([^)]*PROCESS_VM_READ/) + expect(source).not.toMatch(/ReadProcessMemory\(/) + } + // Memory and CPU counters kept VM_READ and never read an address space. + expect(processSource.match(/OpenProcess\(PROCESS_QUERY_LIMITED_INFORMATION/g)).toHaveLength(2) + }) + + it('value-initializes ProcessInfo so memory is not stack garbage', () => { + // Measured before: 82 processes reported the same bogus working set. + expect(processSource).toContain('ProcessInfo pinfo{};') + }) +}) + +type Addon = { + getProcessList: ( + callback: (rows: { pid: number; commandLine?: string }[] | undefined) => void, + flags: number + ) => void +} + +const addonRequire = createRequire(import.meta.url) + +function loadAddon(): Addon { + const packageEntry = addonRequire.resolve('@vscode/windows-process-tree') + return addonRequire( + join(packageEntry, '..', '..', 'build', 'Release', 'windows_process_tree.node') + ) as Addon +} + +// Why fail rather than skip on win32: the published tarball ships a loadable +// prebuilt built from unpatched source, and both readers emit byte-identical +// strings, so a skipping suite would pass against the very binary this patch +// exists to keep out. On win32 the addon must be present, and it must be ours. +describe.runIf(process.platform === 'win32')('windows-process-tree command line addon', () => { + // Why not at collection time: a require that throws there fails the whole + // file, and the patch-text cases above need no binary at all -- a Windows + // checkout without a built addon would lose them to an unrelated failure. + let addon: Addon + beforeAll(() => { + addon = loadAddon() + }) + const children: { kill: () => void }[] = [] + afterAll(() => { + for (const child of children) { + try { + child.kill() + } catch { + // already gone + } + } + }) + + const scan = async (): Promise> => + new Promise((resolveScan) => { + addon.getProcessList((rows) => { + resolveScan(new Map((rows ?? []).map((row) => [row.pid, row.commandLine ?? '']))) + }, 2 /* ProcessDataFlag.CommandLine */) + }) + + it('was built from the patched source, not the published prebuild', () => { + // The patched reader never calls ReadProcessMemory, so the symbol is + // absent from its import table. This is the only check that tells the two + // binaries apart -- a bare require() cannot. + const packageEntry = addonRequire.resolve('@vscode/windows-process-tree') + const binary = readFileSync( + join(packageEntry, '..', '..', 'build', 'Release', 'windows_process_tree.node') + ) + expect(binary.includes('ReadProcessMemory')).toBe(false) + }) + + it('recovers command lines byte-for-byte, quoting and trailing spaces included', async () => { + const marker = `orca-cmdline-${Date.now()}` + // Quotes and trailing whitespace are exactly what a re-quoting bug eats. + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 20000)', `"${marker}" `], { + windowsHide: true, + stdio: 'ignore' + }) + children.push(child) + await new Promise((r) => setTimeout(r, 400)) + + const rows = await scan() + const command = rows.get(child.pid!) + expect(command).toBeDefined() + expect(command).toContain(marker) + expect(command!.endsWith(' "') || command!.endsWith(' ')).toBe(true) + }) + + it('reports the querying process and most of the table', async () => { + const rows = await scan() + expect(rows.has(process.pid)).toBe(true) + const recovered = [...rows.values()].filter((command) => command.length > 0) + // Protected and cross-session processes legitimately deny a handle; a + // wholesale regression would show up as almost nothing recovered. + expect(recovered.length).toBeGreaterThan(rows.size * 0.25) + }) +}) diff --git a/src/main/worktree-create-base-prefetch.test.ts b/src/main/worktree-create-base-prefetch.test.ts index cfe8a76799a..af80afe4b41 100644 --- a/src/main/worktree-create-base-prefetch.test.ts +++ b/src/main/worktree-create-base-prefetch.test.ts @@ -160,12 +160,14 @@ describe('prefetchWorktreeCreateBase local git routing', () => { }) it('does not resolve a local base for SSH repos', async () => { + const prepareCheckout = vi.fn() const provider = { exec: vi.fn() } mocks.getSshGitProvider.mockReturnValue(provider) await expect( prefetchWorktreeCreateBase({ repo: { ...repo, connectionId: 'conn-1' }, + prepareCheckout, baseBranch: 'origin/main', runtime: runtime(), gitOptions: WSL @@ -178,5 +180,145 @@ describe('prefetchWorktreeCreateBase local git routing', () => { { baseBranch: 'origin/main' } ) expect(mocks.gitExecFileAsync).not.toHaveBeenCalled() + expect(prepareCheckout).not.toHaveBeenCalled() }) }) + +describe('checkout and refresh overlap', () => { + it.each([{}, WSL])( + 'starts one checkout before a blocked refresh finishes on %j', + async (gitOptions) => { + const base = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + mocks.resolveRemoteTrackingBase.mockResolvedValue(base) + mocks.hasRemoteTrackingRef.mockResolvedValue(true) + let release!: () => void + mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const prepareCheckout = vi.fn().mockResolvedValue(undefined) + let settled = false + const result = prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions, + prepareCheckout + }).finally(() => { + settled = true + }) + await vi.waitFor(() => expect(prepareCheckout).toHaveBeenCalledWith('origin/main')) + expect(settled).toBe(false) + release() + await expect(result).resolves.toBe('origin/main') + expect(prepareCheckout).toHaveBeenCalledTimes(1) + } + ) + + it('waits for refresh when the selected base is not local', async () => { + mocks.resolveRemoteTrackingBase.mockResolvedValue({ + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + let release!: () => void + mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const prepareCheckout = vi.fn().mockResolvedValue(undefined) + const result = prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: {}, + prepareCheckout + }) + await vi.waitFor(() => + expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledTimes(1) + ) + expect(prepareCheckout).not.toHaveBeenCalled() + release() + await result + expect(prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('does not fail a successful refresh because preparation fails', async () => { + mocks.resolveRemoteTrackingBase.mockResolvedValue({ + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + mocks.hasRemoteTrackingRef.mockResolvedValue(true) + const prepareCheckout = vi.fn().mockRejectedValue(new Error('checkout failed')) + await expect( + prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: {}, + prepareCheckout + }) + ).resolves.toBe('origin/main') + expect(prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('settles preparation before propagating refresh failure', async () => { + mocks.resolveRemoteTrackingBase.mockResolvedValue({ + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + mocks.hasRemoteTrackingRef.mockResolvedValue(true) + const error = new Error('refresh failed') + mocks.getOrStartRemoteTrackingBaseRefresh.mockRejectedValue(error) + let release!: () => void + const prepareCheckout = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + let settled = false + const result = prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: {}, + prepareCheckout + }).finally(() => { + settled = true + }) + const assertion = expect(result).rejects.toBe(error) + await vi.waitFor(() => expect(prepareCheckout).toHaveBeenCalledTimes(1)) + expect(settled).toBe(false) + release() + await assertion + }) +}) + +it('does not prepare folder repositories', async () => { + const prepareCheckout = vi.fn() + await expect( + prefetchWorktreeCreateBase({ + repo: { ...repo, kind: 'folder' }, + runtime: runtime(), + gitOptions: {}, + prepareCheckout + }) + ).resolves.toBeUndefined() + expect(prepareCheckout).not.toHaveBeenCalled() + expect(mocks.gitExecFileAsync).not.toHaveBeenCalled() +}) diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index a0ce1822d9c..8db177a3e9c 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -45,7 +45,8 @@ async function prefetchLocalWorktreeCreateBase( repo: Repo, baseBranch: string | undefined, runtime: WorktreeCreateBasePrefetchRuntime, - options: WorktreeCreateBaseGitOptions + options: WorktreeCreateBaseGitOptions, + prepareLocalCheckout: (base: string) => void ): Promise { // Keep host-routed calls at their original arity so they stay on the runtime's default options. const optionArgs: [] | [WorktreeCreateBaseGitOptions] = options.wslDistro ? [options] : [] @@ -83,8 +84,17 @@ async function prefetchLocalWorktreeCreateBase( ...optionArgs ) if (remoteTrackingBase) { + const hasTrackingRef = await runtime.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...optionArgs + ) + if (hasTrackingRef) { + // Finalization revalidates the refreshed commit before exposing the checkout. + prepareLocalCheckout(resolvedBaseBranch) + } if ( - (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...optionArgs)) || + hasTrackingRef || !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch, options)) ) { await runtime.getOrStartRemoteTrackingBaseRefresh( @@ -114,6 +124,7 @@ export async function prefetchWorktreeCreateBase(args: { /** Routing for the project's Git host; required so a caller cannot silently * warm up the wrong ref store — pass `{}` for host Git. */ gitOptions: WorktreeCreateBaseGitOptions + prepareCheckout?: (base: string) => Promise }): Promise { if (isFolderRepo(args.repo)) { return undefined @@ -126,5 +137,29 @@ export async function prefetchWorktreeCreateBase(args: { await prefetchRemoteWorktreeCreateBase(provider, args.repo, { baseBranch: args.baseBranch }) return undefined } - return prefetchLocalWorktreeCreateBase(args.repo, args.baseBranch, args.runtime, args.gitOptions) + const prepareCheckout = args.prepareCheckout + let preparation: Promise | undefined + const prepare = (base: string): void => { + if (!preparation && prepareCheckout) { + preparation = Promise.resolve() + .then(() => prepareCheckout(base)) + .catch(() => {}) + } + } + try { + const base = await prefetchLocalWorktreeCreateBase( + args.repo, + args.baseBranch, + args.runtime, + args.gitOptions, + prepare + ) + if (base) { + prepare(base) + } + return base + } finally { + // Settle speculative work even if refresh fails; Create owns error reporting. + await preparation + } } diff --git a/src/main/worktree-create-preparation-cancellation.test.ts b/src/main/worktree-create-preparation-cancellation.test.ts new file mode 100644 index 00000000000..7974dcb7e3d --- /dev/null +++ b/src/main/worktree-create-preparation-cancellation.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WorktreeLogic from './ipc/worktree-logic' +import type { Store } from './persistence' +import { WORKTREE_CREATE_PREPARATION_TTL_MS } from './worktree-create-preparation-pool' +import type { Repo } from '../shared/repo-types' +import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + listWorktreeGraph: vi.fn(), + prepareCheckout: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + unlock: vi.fn(), + getWorktreeOptions: vi.fn(), + computeWorkspaceRoot: vi.fn(), + computeWorkspaceRootAsync: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepareCheckout, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: mocks.unlock +})) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('./git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('./ipc/worktree-logic', async (importOriginal) => ({ + isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, + computeWorkspaceRoot: mocks.computeWorkspaceRoot, + computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, + getWorktreePathSettings: () => ({ + workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', + nestWorkspaces: false + }) +})) + +import { + _resetWorktreeCreatePreparationsForTests, + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' + +// Evictions and retries are fire-and-forget, so let them settle before asserting. +function flushBackgroundWork(ms = 0): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const EXISTING_REFS = new Set([ + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/origin/release' +]) +const repo = { id: 'repo-1', path: '/repo' } as Repo +const store = { getSettings: () => ({}) } as unknown as Store + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.unlock.mockReset().mockResolvedValue(undefined) + mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.measureDivergence.mockReset().mockResolvedValue('within') + mocks.resolveBaseRef + .mockReset() + .mockImplementation((_repoPath: string, baseRef: string) => + resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) + ) + mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { + throw new Error('synchronous workspace-root lookup must not run on the main thread') + }) + mocks.computeWorkspaceRootAsync + .mockReset() + .mockImplementation(async (repoPath: string) => + process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) + ? 'C:\\workspace' + : '/workspace' + ) +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() +}) + +// Why this file exists separately from worktree-create-preparation.test.ts: it holds the +// in-flight checkout cancellation paths (eviction, expiry, caller abort) and the discard that +// follows, keeping both suites under the test-file line limit. +describe('worktree create preparation cancellation', () => { + it('cancels an evicted checkout and cleans up with the original options', async () => { + let signal: AbortSignal | undefined + mocks.prepareCheckout.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + }) + }) + const obsolete = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const settled = Promise.allSettled([obsolete]) + await flushBackgroundWork() + const obsoletePath = mocks.prepareCheckout.mock.calls[0][1] + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + expect(signal?.aborted).toBe(true) + expect((await settled)[0].status).toBe('rejected') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, obsoletePath, {}) + }) + + it('does not retry a discard whose registration the aborted checkout already removed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.prepareCheckout.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + const signal = options.signal! + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + try { + const obsolete = prepareWorktreeCreateForRepo(store, repo, 'origin/main').catch(() => {}) + await flushBackgroundWork() + const obsoletePath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === obsoletePath) { + throw Object.assign(new Error(`fatal: '${path}' is not a working tree`), { + stderr: `fatal: '${path}' is not a working tree` + }) + } + }) + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await obsolete + await flushBackgroundWork() + const obsoleteDiscards = (): number => + mocks.discard.mock.calls.filter((call) => call[1] === obsoletePath).length + expect(obsoleteDiscards()).toBe(1) + + for (const base of ['origin/four', 'origin/five']) { + await prepareWorktreeCreateForRepo(store, repo, base) + await flushBackgroundWork() + } + expect(obsoleteDiscards()).toBe(1) + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + it('does not start obsolete checkout work after shared cleanup finishes', async () => { + let releaseCleanup!: () => void + mocks.listWorktreeGraph.mockImplementationOnce( + () => + new Promise<[]>((resolve) => { + releaseCleanup = () => resolve([]) + }) + ) + const requests = ['main', 'one', 'two', 'three'].map((base) => + prepareWorktreeCreateForRepo(store, repo, `origin/${base}`) + ) + const settled = Promise.allSettled(requests) + await flushBackgroundWork() + expect(mocks.prepareCheckout).not.toHaveBeenCalled() + releaseCleanup() + const results = await settled + expect(results.map((result) => result.status)).toEqual([ + 'rejected', + 'fulfilled', + 'fulfilled', + 'fulfilled' + ]) + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('keeps a claimed in-flight checkout alive when new preparations fill the pool', async () => { + let signal: AbortSignal | undefined + let finishCheckout!: () => void + mocks.prepareCheckout.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((resolve) => { + finishCheckout = resolve + }) + }) + const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const create = consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/claimed', + branch: 'claimed', + baseBranch: 'origin/main' + }) + await flushBackgroundWork() + for (const base of ['origin/one', 'origin/two', 'origin/three', 'origin/four']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + expect(signal?.aborted).toBe(false) + finishCheckout() + await preparation + expect(await create).toMatchObject({ status: 'hit' }) + }) + + it('cancels an expired in-flight checkout', async () => { + vi.useFakeTimers() + let signal: AbortSignal | undefined + mocks.prepareCheckout.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + }) + }) + try { + const settled = Promise.allSettled([prepareWorktreeCreateForRepo(store, repo, 'origin/main')]) + await vi.advanceTimersByTimeAsync(0) + expect(signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + expect(signal?.aborted).toBe(true) + expect((await settled)[0].status).toBe('rejected') + } finally { + vi.useRealTimers() + } + }) + + it('preserves caller cancellation without mutating its options', async () => { + const controller = new AbortController() + const options = { signal: controller.signal } + mocks.getWorktreeOptions.mockReturnValue(options) + let signal: AbortSignal | undefined + mocks.prepareCheckout.mockImplementationOnce((_repo, _path, _base, _lock, executionOptions) => { + signal = executionOptions.signal + return new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + }) + }) + const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const settled = Promise.allSettled([preparation]) + await flushBackgroundWork() + controller.abort() + expect(signal?.aborted).toBe(true) + expect((await settled)[0].status).toBe('rejected') + expect(options.signal).toBe(controller.signal) + expect(signal).not.toBe(controller.signal) + }) +}) diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index 7539c6076e4..8251e59a94b 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -11,7 +11,7 @@ import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation import { toHostFilesystemPath } from './host-tree-removal' import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' import { - cleanupStalePreparations, + startStalePreparationCleanup, hasPendingStalePreparationCleanup, resetStalePreparationCleanupForTests } from './worktree-create-preparation-stale-cleanup' @@ -38,6 +38,8 @@ export type PreparationEntry = { createdAt: number ready: Promise expiration: NodeJS.Timeout + controller: AbortController + checkoutStarted: boolean } export type StartPreparationArgs = { @@ -68,6 +70,9 @@ async function discardEntry(entry: PreparationEntry): Promise { // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the // registration for the same reason the discard here can. Enrol either way. await entry.ready.catch(() => {}) + if (!entry.checkoutStarted) { + return + } await discardPreparationWithRetry({ hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), repoPath: entry.repoPath, @@ -86,6 +91,7 @@ function expireEntry(entry: PreparationEntry): void { return } preparations.delete(entry.key) + entry.controller.abort() discardEntryInBackground(entry) } @@ -118,6 +124,7 @@ function enforcePreparationLimit( } preparations.delete(victim.key) clearTimeout(victim.expiration) + victim.controller.abort() discardEntryInBackground(victim) } } @@ -163,6 +170,10 @@ export function startPreparation({ WORKTREE_CREATE_PREPARATION_DIRECTORY ) const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId) + const controller = new AbortController() + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal const entry = {} as PreparationEntry const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) expiration.unref() @@ -179,17 +190,23 @@ export function startPreparation({ options, createdAt: Date.now(), expiration, + controller, + checkoutStarted: false, ready: (async () => { - await cleanupStalePreparations(preparationHostKey(repoPathKey, wslDistro), repoPath, options) - await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) - // Already canonical, so the add re-resolves nothing. - await prepareWorktreeCreateCheckout( + await startStalePreparationCleanup( + preparationHostKey(repoPathKey, wslDistro), repoPath, - preparedPath, - canonicalBase, - lockReason, options ) + signal.throwIfAborted() + await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) + signal.throwIfAborted() + // Already canonical, so the add re-resolves nothing. + entry.checkoutStarted = true + await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, { + ...options, + signal + }) })() } satisfies PreparationEntry) preparations.set(key, entry) @@ -205,7 +222,7 @@ export function startPreparation({ export async function _resetPreparationPoolForTests(): Promise { const entries = [...preparations.values()] preparations.clear() - resetStalePreparationCleanupForTests() + await resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { clearTimeout(entry.expiration) diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts index fd02b7cc0d1..1606e62ed8f 100644 --- a/src/main/worktree-create-preparation-stale-cleanup.ts +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -10,7 +10,7 @@ import { retryPendingPreparationDiscards } from './worktree-preparation-discard- const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 -const staleCleanupInFlight = new Map>() +const staleCleanupInFlight = new Map; settled: Promise }>() function isProcessAlive(pid: number): boolean { try { @@ -21,26 +21,24 @@ function isProcessAlive(pid: number): boolean { } } -/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst - * of arming calls shares one worktree listing. */ -export async function cleanupStalePreparations( +/** Returns after the shared host scan; file reclamation stays tracked in the background. */ +export async function startStalePreparationCleanup( cleanupKey: string, repoPath: string, options: AddWorktreeOptions ): Promise { const existing = staleCleanupInFlight.get(cleanupKey) if (existing) { - await existing.catch(() => {}) + await existing.scanned.catch(() => {}) return } - const cleanup = (async () => { - // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus - // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. - void retryPendingPreparationDiscards(cleanupKey) - const worktrees = await listWorktreeGraph(repoPath, { - ...options, - includeCreatePreparations: true - }) + void retryPendingPreparationDiscards(cleanupKey) + const scan = listWorktreeGraph(repoPath, { + ...options, + includeCreatePreparations: true + }) + const scanned = scan.then(() => {}) + const cleanup = scan.then(async (worktrees) => { const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) let nextIndex = 0 async function discardNextStalePreparation(): Promise { @@ -63,22 +61,27 @@ export async function cleanupStalePreparations( } const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) - })() - staleCleanupInFlight.set(cleanupKey, cleanup) - try { - await cleanup.catch(() => {}) - } finally { - if (staleCleanupInFlight.get(cleanupKey) === cleanup) { - staleCleanupInFlight.delete(cleanupKey) - } - } + }) + // Keep reclamation single-flighted, but do not make a new checkout wait for old file removal. + const entry = { scanned, settled: cleanup } + staleCleanupInFlight.set(cleanupKey, entry) + void cleanup + .catch(() => {}) + .finally(() => { + if (staleCleanupInFlight.get(cleanupKey) === entry) { + staleCleanupInFlight.delete(cleanupKey) + } + }) + await scanned.catch(() => {}) } -/** True while a crash-recovery scan is running, which means a create is in flight or imminent. */ +/** Keeps repo maintenance paused through crash-recovery scanning and reclamation. */ export function hasPendingStalePreparationCleanup(): boolean { return staleCleanupInFlight.size > 0 } -export function resetStalePreparationCleanupForTests(): void { +export async function resetStalePreparationCleanupForTests(): Promise { + const cleanups = [...staleCleanupInFlight.values()].map((entry) => entry.settled) staleCleanupInFlight.clear() + await Promise.allSettled(cleanups) } diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 06818fec422..785ed094b33 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WorktreeLogic from './ipc/worktree-logic' import type { Store } from './persistence' +import { hasPendingStalePreparationCleanup } from './worktree-create-preparation-stale-cleanup' import type { Repo } from '../shared/repo-types' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' @@ -36,7 +38,8 @@ vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: () => undefined })) -vi.mock('./ipc/worktree-logic', () => ({ +vi.mock('./ipc/worktree-logic', async (importOriginal) => ({ + isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, computeWorkspaceRoot: mocks.computeWorkspaceRoot, computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, getWorktreePathSettings: () => ({ @@ -364,7 +367,7 @@ describe('worktree create preparation registry', () => { expect.any(String), 'refs/remotes/origin/main', expect.any(String), - options + { ...options, signal: expect.any(AbortSignal) } ) expect(mocks.finalize).toHaveBeenCalledWith( repo.path, @@ -385,6 +388,60 @@ describe('worktree create preparation registry', () => { expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(2) }) + it('prepares while stale removal is stalled, shares its scan, and settles removal on reset', async () => { + const stalePath = '/workspace/.orca-preparing/999999999-11111111-1111-4111-8111-111111111111' + let releaseRemoval!: () => void + const removal = new Promise((resolve) => { + releaseRemoval = resolve + }) + mocks.listWorktreeGraph.mockResolvedValueOnce([ + { + path: stalePath, + branch: undefined, + lockReason: 'orca-create-preparation:v1:999999999:stale', + head: 'deadbeef', + isBare: false, + isMainWorktree: false + } + ]) + mocks.discard.mockImplementation((_repo, path) => + path === stalePath ? removal : Promise.resolve() + ) + let ready = false + let reset: Promise | undefined + const preparation = prepareWorktreeCreateForRepo(store, repo, 'origin/main').then(() => { + ready = true + }) + try { + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, stalePath, {}) + expect(ready).toBe(true) + await prepareWorktreeCreateForRepo(store, repo, 'origin/release') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) + expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(1) + expect(hasPendingStalePreparationCleanup()).toBe(true) + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(2) + expect(mocks.listWorktreeGraph).toHaveBeenLastCalledWith(repo.path, { + wslDistro: 'Ubuntu', + includeCreatePreparations: true + }) + let resetFinished = false + reset = _resetWorktreeCreatePreparationsForTests().then(() => { + resetFinished = true + }) + await flushBackgroundWork() + expect(resetFinished).toBe(false) + } finally { + releaseRemoval() + await preparation + await reset + } + expect(hasPendingStalePreparationCleanup()).toBe(false) + }) + it('unlocks a stale branch-attached final path instead of deleting user work', async () => { mocks.listWorktreeGraph.mockResolvedValueOnce([ { diff --git a/src/main/worktree-preparation-discard-retry.ts b/src/main/worktree-preparation-discard-retry.ts index e18f890084c..8602bebb39a 100644 --- a/src/main/worktree-preparation-discard-retry.ts +++ b/src/main/worktree-preparation-discard-retry.ts @@ -1,5 +1,6 @@ import type { AddWorktreeOptions } from './git/worktree' import { discardPreparedWorktree } from './git/worktree-create-preparation' +import { isOrphanedWorktreeError } from './ipc/worktree-logic' // Stale cleanup only reclaims preparations whose owner pid is dead, so a discard that fails inside // the live process would strand its scratch checkout until the app restarts. Remember the failure @@ -31,6 +32,11 @@ async function runDiscard(target: PreparationDiscardTarget, attempts: number): P try { await discardPreparedWorktree(target.repoPath, target.preparedPath, target.options) } catch (error) { + // An aborted or failed checkout self-discards first, so the registration is usually already + // gone by the time the pool discards; retrying that would only spawn Git to fail again. + if (isOrphanedWorktreeError(error)) { + return + } // Bounded: a path that never becomes removable must not tax every later preparation. if (attempts >= PREPARATION_DISCARD_ATTEMPT_LIMIT) { console.warn( diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index fc128e14bfb..d541c32dfd8 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -119,7 +119,7 @@ export type BrowserApi = { callback: (data: { worktreeId: string | null; browserPageId: string }) => void ) => () => void onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string }) => void + callback: (event: { browserPageId: string; url: string; activate?: boolean }) => void ) => () => void cancelDownload: (args: { downloadId: string }) => Promise setGrabMode: (args: BrowserSetGrabModeArgs) => Promise diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 93d6001e61c..c2f72ff7cb6 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -71,11 +71,11 @@ export const browserPageInteractionAndSessionsApi = { return () => ipcRenderer.removeListener('browser:pane-focus', listener) }, onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string }) => void + callback: (event: { browserPageId: string; url: string; activate?: boolean }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string } + data: { browserPageId: string; url: string; activate?: boolean } ) => callback(data) ipcRenderer.on('browser:open-link-in-orca-tab', listener) return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 8de2ad3394c..6e1144cdf4f 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -6,6 +6,7 @@ * and git grep as universal fallbacks — git is always available since this is * a git-focused app. */ +import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' import { spawn } from 'node:child_process' import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' @@ -277,7 +278,7 @@ export function searchWithGitGrep( const gitArgs = buildGitGrepArgs(query, opts) const matchRegex = buildSubmatchRegex(query, opts) const acc = createAccumulator() - let stdoutBuffer = '' + const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) let done = false const child = spawn('git', gitArgs, { @@ -292,6 +293,7 @@ export function searchWithGitGrep( return } done = true + lines.clear() clearTimeout(killTimeout) // Why: child.kill() is advisory. If git ignores it, detach our // closures so repeated relay searches do not retain old scans. @@ -310,12 +312,7 @@ export function searchWithGitGrep( } function handleStdoutData(chunk: string): void { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const l of lines) { - processLine(l) - } + lines.push(chunk, processLine) } function handleStderrData(): void { @@ -327,8 +324,9 @@ export function searchWithGitGrep( } function handleClose(): void { - if (stdoutBuffer) { - processLine(stdoutBuffer) + const tail = lines.finish() + if (tail !== null) { + processLine(tail) } resolveOnce() } diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a9df29dd2f2..7d501e56d66 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -5,6 +5,7 @@ * These functions depend only on their arguments (plus `rg` being on PATH), * so they are straightforward to test independently. */ +import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' import { spawn } from 'node:child_process' import { open } from 'node:fs/promises' import { @@ -99,7 +100,7 @@ export function searchWithRg( return new Promise((resolve, reject) => { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() - let buffer = '' + const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -127,6 +128,7 @@ export function searchWithRg( return } resolved = true + lines.clear() clearTimeout(killTimeout) // Why: child.kill() is advisory over SSH; detach listeners if the // process ignores timeout kill so old searches cannot retain closures. @@ -146,6 +148,7 @@ export function searchWithRg( return } resolved = true + lines.clear() clearTimeout(killTimeout) child.stdout!.off('data', handleStdoutData) child.stderr!.off('data', handleStderrData) @@ -179,12 +182,7 @@ export function searchWithRg( } function handleStdoutData(chunk: string): void { - buffer += chunk - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } + lines.push(chunk, processLine) } function handleStderrData(): void { @@ -210,8 +208,9 @@ export function searchWithRg( settleLaunchFailure() return } - if (buffer) { - processLine(buffer) + const tail = lines.finish() + if (tail !== null) { + processLine(tail) } resolveOnce() } diff --git a/src/relay/fs-search-line-fragments.test.ts b/src/relay/fs-search-line-fragments.test.ts new file mode 100644 index 00000000000..53b8902ddfd --- /dev/null +++ b/src/relay/fs-search-line-fragments.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('node:child_process', () => ({ spawn: spawnMock })) + +import { searchWithGitGrep } from './fs-handler-git-fallback' +import { searchWithRg } from './fs-handler-utils' + +function createProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + stdout: Object.assign(new EventEmitter(), { setEncoding: vi.fn() }), + stderr: new EventEmitter(), + kill: vi.fn() + }) as unknown as ChildProcess +} + +const searchCases = [ + { + name: 'ripgrep', + search: searchWithRg, + encode: (text: string, line: number) => + JSON.stringify({ + type: 'match', + data: { + path: { text: '/remote/root/unicode.ts' }, + lines: { text: `${text}\n` }, + line_number: line, + submatches: [{ start: 0, end: 3 }] + } + }) + }, + { + name: 'git grep', + search: searchWithGitGrep, + encode: (text: string, line: number) => `unicode.ts\0${line}\0${text}` + } +] + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + spawnMock.mockReset() +}) + +describe.each(searchCases)('relay $name line fragments', ({ search, encode }) => { + async function run(chunks: string[]) { + const child = createProcess() + spawnMock.mockReturnValueOnce(child) + const result = search('/remote/root', 'hit', { maxResults: 100 }) + expect(child.stdout!.setEncoding).toHaveBeenCalledWith('utf-8') + for (const chunk of chunks) { + child.stdout!.emit('data', chunk) + } + child.emit('close', 0, null) + const value = await result + expect(child.stdout!.listenerCount('data')).toBe(0) + expect(child.stderr!.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.kill).not.toHaveBeenCalled() + return value + } + + it('preserves decoded Unicode, batched lines, empty lines and the final unterminated match', async () => { + const text = 'hit café 漢字 🐋' + const wire = `${encode(text, 1)}\n\n${encode('hit second', 2)}\n${encode(text, 3)}` + const complete = await run([wire]) + const fragmented = await run(Array.from(wire)) + expect(fragmented).toEqual(complete) + expect(fragmented.totalMatches).toBe(3) + expect(fragmented.truncated).toBe(false) + expect(fragmented.files[0].matches.map((match) => match.line)).toEqual([1, 2, 3]) + expect(fragmented.files[0].matches[0].lineContent).toBe(text) + expect(fragmented.files[0].matches[2].lineContent).toBe(text) + }) + + it('does not repeatedly split the growing partial output of a large matching line', async () => { + const wire = `${encode(`hit ${'x'.repeat(1024 * 1024)}`, 7)}\n` + const complete = await run([wire]) + const chunks: string[] = [] + for (let offset = 0; offset < wire.length; offset += 4096) { + chunks.push(wire.slice(offset, offset + 4096)) + } + const originalSplit = String.prototype.split + let scannedCharacters = 0 + const spy = vi.spyOn(String.prototype, 'split').mockImplementation(function ( + this: string, + separator: unknown, + limit?: number + ) { + if (separator === '\n') { + scannedCharacters += this.length + } + return Reflect.apply(originalSplit, this, [separator, limit]) + }) + let fragmented + try { + fragmented = await run(chunks) + } finally { + spy.mockRestore() + } + expect(fragmented).toEqual(complete) + expect(fragmented.totalMatches).toBe(1) + expect(fragmented.files[0].matches[0].line).toBe(7) + expect(scannedCharacters).toBe(0) + }) + + it('discards an unfinished line on timeout and detaches the output listeners', async () => { + vi.useFakeTimers() + const child = createProcess() + spawnMock.mockReturnValueOnce(child) + const result = search('/remote/root', 'hit', { maxResults: 100 }) + child.stdout!.emit('data', `${encode('hit complete', 1)}\n${encode('hit partial', 2)}`) + await vi.runOnlyPendingTimersAsync() + const value = await result + expect(value.totalMatches).toBe(1) + expect(value.truncated).toBe(true) + expect(child.kill).toHaveBeenCalled() + expect(child.stdout!.listenerCount('data')).toBe(0) + expect(child.stderr!.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + child.stdout!.emit('data', '\n') + child.emit('close', 0, null) + expect(value.totalMatches).toBe(1) + }) +}) diff --git a/src/relay/windows-port-scan.test.ts b/src/relay/windows-port-scan.test.ts index 139d3ede6be..9fb074180b9 100644 --- a/src/relay/windows-port-scan.test.ts +++ b/src/relay/windows-port-scan.test.ts @@ -1,22 +1,30 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileAsyncMock, execFileMock, promisifyCustom } = vi.hoisted(() => ({ - execFileAsyncMock: vi.fn(), - execFileMock: vi.fn(), - promisifyCustom: Symbol.for('nodejs.util.promisify.custom') -})) - -vi.mock('child_process', () => ({ - execFile: Object.assign(execFileMock, { - [promisifyCustom]: execFileAsyncMock - }) +const runProcessMock = vi.fn() +vi.mock('../shared/child-process/run-process', () => ({ + runProcess: (spec: unknown) => runProcessMock(spec) })) vi.mock('./relay-command-env', () => ({ buildRelayCommandEnv: () => ({ PATH: 'C:\\Windows\\System32' }) })) -const { scanWindowsListeningPorts } = await import('./windows-port-scan') +import { + __setWindowsProcessTableCimScanForTests, + __setWindowsProcessTreeLoaderForTests, + resetWindowsProcessTableForTests +} from '../main/windows/windows-process-table' +import { + resetWindowsPortScanDiagnosticsForTests, + scanWindowsListeningPorts +} from './windows-port-scan' + +type Spec = { + program: string + args?: readonly string[] + timeoutMs?: number | null + signal?: AbortSignal +} // The scanner drops any row whose pid is the relay process or its parent, so a fixture pid // that happens to match the vitest worker's own pid silently empties the result and the @@ -32,78 +40,365 @@ function pidUnlikeSelf(seed: number): number { return pid } -const POWERSHELL_PID = pidUnlikeSelf(1234) const NETSTAT_PID = pidUnlikeSelf(2468) +const SSHD_PID = pidUnlikeSelf(4321) +const POWERSHELL_PID = pidUnlikeSelf(1234) + +const NETSTAT_STDOUT = [ + ' Proto Local Address Foreign Address State PID', + ` TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING ${NETSTAT_PID}`, + ` TCP [::]:3000 [::]:0 LISTENING ${NETSTAT_PID}`, + ` TCP 0.0.0.0:4000 93.184.216.34:443 ESTABLISHED ${NETSTAT_PID}`, + ` UDP 0.0.0.0:5353 *:* ${NETSTAT_PID}`, + ` TCP 0.0.0.0:2222 0.0.0.0:0 LISTENING ${SSHD_PID}` +].join('\r\n') + +function ok(stdout: string): { + code: number + signal: null + stdout: string + stderr: string + timedOut: boolean +} { + return { code: 0, signal: null, stdout, stderr: '', timedOut: false } +} + +type NativeRow = { + pid: number + ppid: number + name: string + memory?: number + commandLine?: string + creationTimeMs?: number +} + +/** A native snapshot must contain the reader's own pid or the table rejects. */ +function nativeTable(rows: NativeRow[]) { + return () => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses: (callback: (processes: NativeRow[] | undefined) => void) => + callback([{ pid: process.pid, ppid: 0, name: 'vitest.exe' }, ...rows]) + }) +} + +function specs(): Spec[] { + return runProcessMock.mock.calls.map((call) => call[0] as Spec) +} describe('scanWindowsListeningPorts', () => { beforeEach(() => { - execFileAsyncMock.mockReset() + runProcessMock.mockReset() + resetWindowsPortScanDiagnosticsForTests() + resetWindowsProcessTableForTests() + __setWindowsProcessTreeLoaderForTests( + nativeTable([ + { pid: NETSTAT_PID, ppid: 4, name: 'node.exe' }, + { pid: SSHD_PID, ppid: 4, name: 'sshd.exe' } + ]) + ) }) - it('bounds the PowerShell scan with the caller abort signal and timeout', async () => { + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + __setWindowsProcessTableCimScanForTests() + resetWindowsProcessTableForTests() + }) + + it('reads netstat first and never starts PowerShell', async () => { const controller = new AbortController() - execFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ + runProcessMock.mockResolvedValueOnce(ok(NETSTAT_STDOUT)) + + await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ + { host: '::', port: 3000, pid: NETSTAT_PID, processName: 'node' }, + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } + ]) + + expect(specs()).toHaveLength(1) + expect(specs()[0].program).toMatch(/netstat\.exe$/) + // `-p tcp` is absent on purpose: on Windows it means IPv4-only and would + // hide every `[::]` listener. + expect(specs()[0].args).toEqual(['-ano']) + expect(specs()[0].signal).toBe(controller.signal) + expect(specs()[0].timeoutMs).toBe(5000) + }) + + it('keeps the sshd and self-pid filters working off native process names', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + [ + NETSTAT_STDOUT, + ` TCP 0.0.0.0:9999 0.0.0.0:0 LISTENING ${process.pid}` + ].join('\r\n') + ) + ) + + const ports = await scanWindowsListeningPorts() + + // sshd.exe is matched despite the table's `.exe` spelling, and the relay's + // own listener never reaches a client. + expect(ports.map((port) => `${port.host}:${port.port}`)).toEqual([':::3000', '0.0.0.0:3000']) + }) + + it('still reports host/port/pid when no process table is readable', async () => { + runProcessMock.mockResolvedValueOnce(ok(NETSTAT_STDOUT)) + __setWindowsProcessTreeLoaderForTests(() => null) + __setWindowsProcessTableCimScanForTests(() => + Promise.reject(new Error('windows process table unavailable')) + ) + resetWindowsProcessTableForTests() + + await expect(scanWindowsListeningPorts()).resolves.toEqual([ + { host: '0.0.0.0', port: 2222, pid: SSHD_PID }, + { host: '::', port: 3000, pid: NETSTAT_PID }, + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID } + ]) + // Names were unavailable, so nothing else was spawned to go get them. + expect(specs()).toHaveLength(1) + }) + + it('falls back to PowerShell without an execution-policy override', async () => { + const controller = new AbortController() + runProcessMock + .mockResolvedValueOnce({ + code: 1, + signal: null, + stdout: '', + stderr: 'blocked', + timedOut: false + }) + .mockResolvedValueOnce( + ok( + JSON.stringify({ + host: '127.0.0.1', + port: 5173, + pid: POWERSHELL_PID, + processName: 'node' + }) + ) + ) + + await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ + { host: '127.0.0.1', port: 5173, pid: POWERSHELL_PID, processName: 'node' - }), - stderr: '' - }) - - await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ - { host: '127.0.0.1', port: 5173, pid: POWERSHELL_PID, processName: 'node' } + } ]) - expect(execFileAsyncMock).toHaveBeenCalledWith( - 'powershell.exe', - expect.arrayContaining(['-EncodedCommand', expect.any(String)]), - expect.objectContaining({ - signal: controller.signal, - timeout: 5000, - windowsHide: true - }) - ) + const powershell = specs()[1] + expect(powershell.program).toMatch(/powershell\.exe$/i) + expect(powershell.args?.slice(0, 3)).toEqual(['-NoProfile', '-NonInteractive', '-Command']) + expect(powershell.args).not.toContain('-ExecutionPolicy') + expect(powershell.args).not.toContain('-EncodedCommand') + expect(powershell.args).toHaveLength(4) + expect(powershell.args?.[3]).toContain('Get-NetTCPConnection') + expect(powershell.signal).toBe(controller.signal) + expect(powershell.timeoutMs).toBe(5000) }) - it('bounds the netstat fallback with the same abort signal and timeout', async () => { - const controller = new AbortController() - execFileAsyncMock + it('tries pwsh when Windows PowerShell cannot answer, then gives up empty', async () => { + runProcessMock + .mockResolvedValueOnce(ok('')) .mockRejectedValueOnce(new Error('powershell unavailable')) .mockRejectedValueOnce(new Error('pwsh unavailable')) - .mockResolvedValueOnce({ - stdout: [ - ' Proto Local Address Foreign Address State PID', - ` TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING ${NETSTAT_PID}` - ].join('\r\n'), - stderr: '' - }) - await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ - { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID } + await expect(scanWindowsListeningPorts()).resolves.toEqual([]) + + expect(specs().map((spec) => spec.program)).toEqual([ + expect.stringMatching(/netstat\.exe$/), + expect.stringMatching(/powershell\.exe$/i), + 'pwsh.exe' ]) - - expect(execFileAsyncMock).toHaveBeenLastCalledWith( - 'netstat.exe', - ['-ano', '-p', 'tcp'], - expect.objectContaining({ - signal: controller.signal, - timeout: 5000, - windowsHide: true - }) - ) }) - it('does not start the netstat fallback after the scan is cancelled', async () => { + it('gives up rather than falling back once the scan is cancelled', async () => { const controller = new AbortController() controller.abort() - execFileAsyncMock.mockRejectedValueOnce( - Object.assign(new Error('cancelled'), { name: 'AbortError' }) - ) + runProcessMock.mockResolvedValueOnce({ + code: null, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([]) - expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + expect(runProcessMock).toHaveBeenCalledTimes(1) + }) + + // `LISTENING` ships in netstat.exe.mui, picked by UI language, so no env can + // pin it. Without the shape-based re-read a German host parses zero rows, + // reads that as a blocked reader, and runs the flagged payload every 12-30s. + it('reads a localized host by socket shape rather than the state word', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + [ + 'Aktive Verbindungen', + '', + ' Proto Lokale Adresse Remoteadresse Status PID', + ' TCP 0.0.0.0:135 0.0.0.0:0 ABHÖREN 1116', + ` TCP 0.0.0.0:3000 0.0.0.0:0 ABHÖREN ${NETSTAT_PID}`, + ` TCP [::]:3000 [::]:0 ABHÖREN ${NETSTAT_PID}`, + ` TCP 192.168.0.5:52000 93.184.216.34:443 HERGESTELLT ${NETSTAT_PID}` + ].join('\r\n') + ) + ) + + await expect(scanWindowsListeningPorts()).resolves.toEqual([ + { host: '0.0.0.0', port: 135, pid: 1116 }, + { host: '::', port: 3000, pid: NETSTAT_PID, processName: 'node' }, + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } + ]) + expect(specs()).toHaveLength(1) + }) + + // The gap the state-word cases cannot cover: on a localized host BOUND is as + // unreadable as ABHÖREN, so shape alone would publish 8080 as a listener. + // Listeners dominate, and that is what separates them. + it('drops a BOUND socket that shape alone would promote on a localized host', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + [ + ` TCP 0.0.0.0:3000 0.0.0.0:0 ABHÖREN ${NETSTAT_PID}`, + ` TCP [::]:3000 [::]:0 ABHÖREN ${NETSTAT_PID}`, + ` TCP 0.0.0.0:8080 0.0.0.0:0 GEBUNDEN ${NETSTAT_PID}`, + ` TCP 192.168.0.5:52000 93.184.216.34:443 HERGESTELLT ${NETSTAT_PID}` + ].join('\r\n') + ) + ) + + await expect(scanWindowsListeningPorts()).resolves.toEqual([ + { host: '::', port: 3000, pid: NETSTAT_PID, processName: 'node' }, + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } + ]) + }) + + // Only on an exact tie does shape have nothing left to go on, and then it + // keeps both rather than guessing — no worse than reading shape alone. + it('keeps every tied zero-peer state when none dominates', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + [ + ` TCP 0.0.0.0:3000 0.0.0.0:0 ABHÖREN ${NETSTAT_PID}`, + ` TCP 0.0.0.0:8080 0.0.0.0:0 GEBUNDEN ${NETSTAT_PID}` + ].join('\r\n') + ) + ) + + await expect(scanWindowsListeningPorts()).resolves.toEqual([ + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' }, + { host: '0.0.0.0', port: 8080, pid: NETSTAT_PID, processName: 'node' } + ]) + }) + + // Windows prints BOUND with a zero peer too, so the shape test must stay the + // fallback: a host with at least one readable LISTENING row never reaches it. + it('does not promote a BOUND socket on a host whose state word parsed', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + [ + ` TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING ${NETSTAT_PID}`, + ` TCP 0.0.0.0:8080 0.0.0.0:0 BOUND ${NETSTAT_PID}` + ].join('\r\n') + ) + ) + + await expect(scanWindowsListeningPorts()).resolves.toEqual([ + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID, processName: 'node' } + ]) + }) + + // A capped read exits 0 and its head parses, and netstat orders IPv4 TCP + // before IPv6 TCP, so publishing the head would drop every `[::]` listener. + it('refuses a netstat table that hit the capture cap', async () => { + const filler = Array.from( + { length: 60_000 }, + (_, index) => + ` TCP 10.0.0.1:${1000 + (index % 5000)} 10.0.0.2:443 TIME_WAIT 4` + ).join('\r\n') + runProcessMock + .mockResolvedValueOnce(ok(`${NETSTAT_STDOUT}\r\n${filler}`.slice(0, 4 * 1024 * 1024))) + .mockResolvedValueOnce(ok('[]')) + + await expect(scanWindowsListeningPorts()).resolves.toEqual([]) + + // Fell through instead of publishing the IPv4 head it could still parse. + expect(specs()).toHaveLength(2) + expect(specs()[1].args).toContain('-Command') + }) + + it('does not wait on the shared process table once the scan is cancelled', async () => { + const controller = new AbortController() + const getAllProcesses = vi.fn() + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + resetWindowsProcessTableForTests() + // netstat answered, then the request was abandoned before names were needed. + runProcessMock.mockImplementationOnce(() => { + controller.abort() + return Promise.resolve(ok(NETSTAT_STDOUT)) + }) + + await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ + // Unnamed, so the sshd row survives its own filter — the cost of not + // waiting, and strictly better than blocking an abandoned request. + { host: '0.0.0.0', port: 2222, pid: SSHD_PID }, + { host: '::', port: 3000, pid: NETSTAT_PID }, + { host: '0.0.0.0', port: 3000, pid: NETSTAT_PID } + ]) + expect(getAllProcesses).not.toHaveBeenCalled() + }) + + // The relay daemon's stderr is what installRelayLogRotation routes into + // relay.log, so a fall-through logged anywhere else is a fall-through nobody + // can diagnose. Pin the stream, not just the fact that something was called. + it('reports leaving the native path on the relay diagnostic stream, once', async () => { + const lines: string[] = [] + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + lines.push(String(chunk)) + return true + }) + try { + runProcessMock.mockResolvedValue(ok('')) + await scanWindowsListeningPorts() + await scanWindowsListeningPorts() + // A second, different fault on the same host must still be heard: one + // flag for the whole module would have swallowed it. + runProcessMock.mockResolvedValue(ok('x'.repeat(4 * 1024 * 1024))) + await scanWindowsListeningPorts() + await scanWindowsListeningPorts() + } finally { + stderr.mockRestore() + } + + const reported = lines.filter((line) => line.includes('[ports] netstat unusable')) + expect(reported).toHaveLength(2) + expect(reported[0]).toContain('no listening row parsed') + expect(reported[1]).toContain('truncated') + // relayLogLine's ISO stamp: an unplaceable line cannot be read against the + // reconnect flaps around it. + expect(reported[0]).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z /) + }) + + it('treats a netstat timeout as unanswered and falls through', async () => { + runProcessMock + .mockResolvedValueOnce({ + code: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + timedOut: true + }) + .mockResolvedValueOnce(ok('[]')) + + await expect(scanWindowsListeningPorts()).resolves.toEqual([]) + + expect(specs()).toHaveLength(2) }) }) diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts index f26a2bb172a..f99fce4a087 100644 --- a/src/relay/windows-port-scan.ts +++ b/src/relay/windows-port-scan.ts @@ -1,84 +1,231 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' +import { readWindowsProcessTable } from '../main/windows/windows-process-table' +import { runProcess } from '../shared/child-process/run-process' +import { + windowsPowerShellPath, + windowsSystem32Binary +} from '../shared/child-process/windows-system-binary' import { getProcessOutputFields } from '../shared/process-output-field-scanner' -import { encodePowerShellCommand } from '../shared/powershell-command-encoding' import type { DetectedPort } from './port-scan-handler' import { buildRelayCommandEnv } from './relay-command-env' +import { relayLogLine } from './relay-diagnostic-log' const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) const MAX_DETECTED_PORTS = 50 const WINDOWS_PORT_SCAN_TIMEOUT_MS = 5_000 -const execFileAsync = promisify(execFile) +// Wide enough for `netstat -ano` on a busy host: it prints every connection, not +// just the listeners, and a truncated table silently drops the tail. +const WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES = 4 * 1024 * 1024 +/** + * Listening TCP ports, attributed to their owning process. + * + * `netstat.exe -ano` answers all of it except the process name, which comes + * from the shared process table -- so this scan starts no PowerShell of its + * own. One still runs on a released relay: without the optional + * `windows-process-tree.node` addon (built only by dev-channel-win-build.yml, + * so no release carries it) that table falls back to a CIM scan that forks one + * `powershell.exe`. That scan is TTL-shared with pane naming, so a relay with a + * live pane pays nothing extra for it. + * + * The EDR win is therefore the shape, not the absence of PowerShell. The + * retired payload ran `-ExecutionPolicy Bypass -EncodedCommand ` + * wrapping `Get-NetTCPConnection` joined to `Get-Process`: base64 beside a + * policy override is the highest-weighted token pair Defender for Endpoint + * scores on a PowerShell command line, and listing listeners with their owners + * reads as network discovery (T1049) on top of it. The shared CIM scan carries + * neither token. That payload survives only as the last resort below, without + * the override. + */ export async function scanWindowsListeningPorts(signal?: AbortSignal): Promise { + const netstatPorts = await readWindowsNetstatPorts(signal) + if (netstatPorts) { + return normalizeWindowsDetectedPorts(await attachWindowsProcessNames(netstatPorts, signal)) + } + if (signal?.aborted) { + return [] + } try { const json = await runWindowsPortScanPowerShell(signal) return normalizeWindowsDetectedPorts(parseWindowsPowerShellPortRows(json)) } catch { - if (signal?.aborted) { - return [] - } - try { - const { stdout } = await execFileAsync('netstat.exe', ['-ano', '-p', 'tcp'], { - env: buildRelayCommandEnv(), - encoding: 'utf-8', - signal, - timeout: WINDOWS_PORT_SCAN_TIMEOUT_MS, - windowsHide: true - }) - return normalizeWindowsDetectedPorts(parseWindowsNetstatOutput(stdout)) - } catch { - return [] - } + return [] } } -async function runWindowsPortScanPowerShell(signal?: AbortSignal): Promise { - const script = [ - "$ErrorActionPreference = 'Stop'", - '$connections = Get-NetTCPConnection -State Listen -ErrorAction Stop', - '$items = foreach ($connection in $connections) {', - ' $name = $null', - ' try {', - ' $process = Get-Process -Id $connection.OwningProcess -ErrorAction Stop', - ' $name = $process.ProcessName', - ' } catch {}', - ' [pscustomobject]@{', - ' host = [string]$connection.LocalAddress', - ' port = [int]$connection.LocalPort', - ' pid = [int]$connection.OwningProcess', - ' processName = $name', - ' }', - '}', - '$items | ConvertTo-Json -Compress -Depth 3' - ].join('\n') - const encoded = encodePowerShellCommand(script) - const lastError: unknown[] = [] +/** Rows, or null when netstat could not answer and the fallback should run. */ +async function readWindowsNetstatPorts(signal?: AbortSignal): Promise { + let stdout: string + try { + const result = await runProcess({ + program: windowsSystem32Binary('netstat.exe'), + // No `-p tcp`: on Windows that protocol name means TCP over IPv4 only, so + // it hides every `[::]` listener the retired PowerShell payload reported. + args: ['-ano'], + env: buildRelayCommandEnv(), + timeoutMs: WINDOWS_PORT_SCAN_TIMEOUT_MS, + maxOutputBytes: WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES, + signal + }) + if (result.timedOut || result.code !== 0) { + return null + } + // A capped read still exits 0 and its head still parses, so nothing + // downstream can tell a partial table from a whole one. netstat prints IPv4 + // TCP, then IPv6 TCP, then UDP, so the rows lost first are exactly the + // `[::]` listeners that dropping `-p tcp` above exists to keep. Refuse the + // whole read rather than publish its head. + if (Buffer.byteLength(result.stdout) >= WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES) { + reportWindowsNetstatUnusable('output hit the capture cap and was truncated') + return null + } + stdout = result.stdout + } catch { + return null + } + const ports = parseWindowsNetstatOutput(stdout) + // Windows always has a listener (RPC endpoint mapper, SMB), so an exit-0 scan + // that parses to nothing is a reader that was blocked, not an idle host. + if (ports.length === 0) { + reportWindowsNetstatUnusable('exited 0 but no listening row parsed') + return null + } + return ports +} - for (const binary of ['powershell.exe', 'pwsh.exe']) { - try { - const { stdout } = await execFileAsync( - binary, - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], - { - env: buildRelayCommandEnv(), - encoding: 'utf-8', - maxBuffer: 1024 * 1024, - signal, - timeout: WINDOWS_PORT_SCAN_TIMEOUT_MS, - windowsHide: true - } +/** Reasons already reported. A fixed two-value vocabulary, so it cannot grow. */ +const reportedNetstatFailures = new Set() + +/** + * Say once why the scan left the native path. + * + * Both fall-throughs are permanent when they are wrong — the host stays on the + * PowerShell payload, or on nothing, for the life of the relay — and the scan + * repeats every 12-30s, so this logs one line rather than a stream. + * + * Through relayLogLine, not console.warn: this only ever runs in the detached + * daemon, whose stderr installRelayLogRotation routes into the relay.log that + * the remote-diagnostics tail reads. An untimestamped line in that file cannot + * be placed against the reconnect flaps around it (#7773), and "since when" is + * most of what this line is for. + */ +function reportWindowsNetstatUnusable(reason: string): void { + // Per reason, not per module: a host that parses nothing today and truncates + // tomorrow has two different faults, and one flag would hide the second. + if (reportedNetstatFailures.has(reason)) { + return + } + reportedNetstatFailures.add(reason) + relayLogLine(`[ports] netstat unusable on this host (${reason}); falling back to PowerShell`) +} + +/** Test-only: re-arm the one-shot so each case can observe its own line. */ +export function resetWindowsPortScanDiagnosticsForTests(): void { + reportedNetstatFailures.clear() +} + +/** + * Fill in owning-process names from the shared process-table snapshot. + * + * Names are optional data — the panel renders host/port/pid without them — so a + * host that cannot read the table keeps its rows. This shares whatever scan the + * table already runs rather than avoiding one, and on a relay that scan is a + * `powershell.exe` CIM query -- no released relay carries the native addon, so + * that is the path every SSH host takes, not a fallback. + * See docs/reference/windows-process-enumeration.md. + * + * Only `name` is read here, so this wants `readWindowsProcessIdentityTable` + * once #17866 lands -- on the detailed reader it would pay per-process handles + * for a field it discards. + * + * Best-effort by design: the snapshot is shared and TTL-cached, so it can + * predate netstat and hand a recycled PID its previous owner's name. Only + * labels read this field, and a fresh read would cost every caller a scan. + */ +async function attachWindowsProcessNames( + ports: DetectedPort[], + signal?: AbortSignal +): Promise { + const pids = new Set(ports.flatMap((port) => (port.pid == null ? [] : [port.pid]))) + // The shared snapshot takes no signal and must not be cancelled on one + // caller's behalf, so an abandoned scan declines to wait for it instead. + if (pids.size === 0 || signal?.aborted) { + return ports + } + let names: Map + try { + const rows = await readWindowsProcessTable() + names = new Map( + rows.flatMap((row) => + pids.has(row.pid) && row.name ? [[row.pid, stripExecutableSuffix(row.name)] as const] : [] ) - return stdout + ) + } catch { + return ports + } + return ports.map((port) => { + const processName = port.pid == null ? undefined : names.get(port.pid) + return processName ? { ...port, processName } : port + }) +} + +// The process table reports `sshd.exe`; the retired `Get-Process` payload +// reported `sshd`. The sshd filter below and every client that already renders +// these rows read the bare name, so keep publishing that spelling. +function stripExecutableSuffix(name: string): string { + return name.replace(/\.exe$/i, '') +} + +/** + * Single line so it survives as one argv element regardless of how the + * shell-less spawn hands it to PowerShell's `-Command` parser. Exported so + * windows-port-scan.win32.test.ts can run it: a missing `;` between statements + * is a parse error the mocked tests cannot see. + */ +export const WINDOWS_PORT_SCAN_SCRIPT = [ + "$ErrorActionPreference = 'Stop';", + 'Get-NetTCPConnection -State Listen | ForEach-Object {', + '$connection = $_; $name = $null;', + 'try { $name = (Get-Process -Id $connection.OwningProcess -ErrorAction Stop).ProcessName } catch { };', + '[pscustomobject]@{ host = [string]$connection.LocalAddress; port = [int]$connection.LocalPort;', + 'pid = [int]$connection.OwningProcess; processName = $name }', + '} | ConvertTo-Json -Compress -Depth 3' +].join(' ') + +async function runWindowsPortScanPowerShell(signal?: AbortSignal): Promise { + let lastError: unknown + + for (const program of [windowsPowerShellPath(), 'pwsh.exe']) { + try { + const result = await runProcess({ + program, + // No `-ExecutionPolicy` override: the policy gates script *files*, never + // `-Command`. Verified on Windows 11 — `-ExecutionPolicy Restricted + // -Command` still runs, while `-File` against an unsigned .ps1 does not. + args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PORT_SCAN_SCRIPT], + env: buildRelayCommandEnv(), + timeoutMs: WINDOWS_PORT_SCAN_TIMEOUT_MS, + maxOutputBytes: WINDOWS_PORT_SCAN_MAX_OUTPUT_BYTES, + signal + }) + if (signal?.aborted) { + throw new Error('windows port scan aborted') + } + if (result.timedOut || result.code !== 0) { + lastError ??= new Error( + `windows port scan PowerShell failed (code=${result.code} timedOut=${result.timedOut})` + ) + continue + } + return result.stdout } catch (error) { if (signal?.aborted) { throw error } - lastError.push(error) + lastError ??= error } } - throw lastError[0] ?? new Error('PowerShell unavailable') + throw lastError ?? new Error('PowerShell unavailable') } export function parseWindowsPowerShellPortRows(json: string): DetectedPort[] { @@ -98,26 +245,85 @@ export function parseWindowsPowerShellPortRows(json: string): DetectedPort[] { return rows.flatMap((row) => parseWindowsPortRow(row)) } +/** + * Listening rows, on a host in any UI language. + * + * `LISTENING` is not in `netstat.exe` — it lives in + * `System32\\netstat.exe.mui` beside `ESTABLISHED` and `Proto`, and MUI + * selection follows the UI language, so the pinned-locale env in + * relay-command-env.ts cannot reach it. A German host prints `ABHÖREN` and the + * word test finds nothing at all. + * + * The shape is language-independent: a listening socket has no peer, so its + * foreign address is `0.0.0.0:0` / `[::]:0`, and on every state Windows prints + * with a real peer that port is non-zero. Shape stays the fallback because the + * converse does not hold — `BOUND` and `CLOSED` print a zero peer too, and on a + * localized host their words are just as unreadable as the listening one. + */ export function parseWindowsNetstatOutput(output: string): DetectedPort[] { - const rows: DetectedPort[] = [] + const { rows, tcpRows } = scanWindowsNetstatTcpRows(output) + const byStateWord = rows.filter((row) => row.state === 'LISTENING') + if (byStateWord.length > 0 || tcpRows === 0) { + return byStateWord.map((row) => row.port) + } + return readDominantZeroPeerState(rows) +} + +/** + * Of the zero-peer states, keep only the one that dominates. + * + * Shape alone would publish a phantom listener: one `BOUND` socket among real + * listeners looks identical to them once the state word is unreadable. But it + * cannot dominate — listeners outnumber those transients by roughly 50:1 on a + * real host (51 against 0 here), so the largest zero-peer group is the + * listening one. An exact tie keeps every tied group rather than guessing, + * which is no worse than reading shape alone. + * + * A majority rule inverts if the majority is wrong: enough transient zero-peer + * sockets and the phantoms win, publishing those and dropping the real + * listeners. The hatch is to return [] here and defer to the PowerShell reader, + * which reads the state word instead of inferring it. + */ +function readDominantZeroPeerState(rows: NetstatTcpRow[]): DetectedPort[] { + const countByState = new Map() + for (const row of rows) { + if (row.zeroPeer) { + countByState.set(row.state, (countByState.get(row.state) ?? 0) + 1) + } + } + const largest = Math.max(0, ...countByState.values()) + const dominant = new Set( + [...countByState].filter(([, count]) => count === largest).map(([state]) => state) + ) + return rows.flatMap((row) => (row.zeroPeer && dominant.has(row.state) ? [row.port] : [])) +} + +type NetstatTcpRow = { state: string; zeroPeer: boolean; port: DetectedPort } + +/** `tcpRows` separates a localized host from one with genuinely no TCP output. */ +function scanWindowsNetstatTcpRows(output: string): { rows: NetstatTcpRow[]; tcpRows: number } { + const rows: NetstatTcpRow[] = [] + let tcpRows = 0 for (const line of output.split(/\r?\n/)) { const fields = getProcessOutputFields(line, 5) if (fields.length < 5 || fields[0].toUpperCase() !== 'TCP') { continue } - if (fields[3].toUpperCase() !== 'LISTENING') { - continue - } + tcpRows += 1 const hostPort = parseWindowsNetstatAddress(fields[1]) const pid = Number.parseInt(fields[4], 10) if (!hostPort || !Number.isSafeInteger(pid) || pid <= 0) { continue } - rows.push({ ...hostPort, pid }) + rows.push({ + state: fields[3].toUpperCase(), + zeroPeer: readWindowsNetstatPort(fields[2]) === 0, + port: { ...hostPort, pid } + }) } - return rows + return { rows, tcpRows } } function parseWindowsPortRow(row: unknown): DetectedPort[] { @@ -165,13 +371,20 @@ function readInteger(value: unknown): number | undefined { return Number.isSafeInteger(parsed) ? parsed : undefined } -function parseWindowsNetstatAddress(value: string): { host: string; port: number } | null { - const ipv6Match = /^\[(.*)\]:(\d+)$/.exec(value) - const portText = ipv6Match?.[2] ?? value.slice(value.lastIndexOf(':') + 1) +/** Port alone, keeping 0 — the foreign-address test above turns on that value. */ +function readWindowsNetstatPort(value: string): number | null { + const ipv6Match = /^\[.*\]:(\d+)$/.exec(value) + const portText = ipv6Match?.[1] ?? value.slice(value.lastIndexOf(':') + 1) const port = Number.parseInt(portText, 10) - if (!Number.isSafeInteger(port) || port <= 0) { + return Number.isSafeInteger(port) ? port : null +} + +function parseWindowsNetstatAddress(value: string): { host: string; port: number } | null { + const port = readWindowsNetstatPort(value) + if (port == null || port <= 0) { return null } + const ipv6Match = /^\[(.*)\]:\d+$/.exec(value) if (ipv6Match) { return { host: ipv6Match[1], port } } diff --git a/src/relay/windows-port-scan.win32.test.ts b/src/relay/windows-port-scan.win32.test.ts new file mode 100644 index 00000000000..976f477cd9c --- /dev/null +++ b/src/relay/windows-port-scan.win32.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' +import { windowsPowerShellPath } from '../shared/child-process/windows-system-binary' +import { + WINDOWS_PORT_SCAN_SCRIPT, + parseWindowsPowerShellPortRows, + scanWindowsListeningPorts +} from './windows-port-scan' + +/** + * The mocked suite pins the argv; this pins that the argv works. + * + * Both halves are things a mock cannot see: netstat's real column layout (a + * `-p tcp` here silently drops every `[::]` listener), and whether the joined + * one-line PowerShell script even parses — a missing `;` between statements is + * a ParserError, and the fallback would then be dead on the day it is needed. + * + * Runs only on win32; skipped elsewhere. + */ +const describeOnWindows = process.platform === 'win32' ? describe : describe.skip + +describeOnWindows('windows port scan against the real host', () => { + it('finds listeners over both address families through netstat', async () => { + const ports = await scanWindowsListeningPorts() + + expect(ports.length).toBeGreaterThan(0) + for (const port of ports) { + expect(port.port).toBeGreaterThan(0) + expect(port.host.length).toBeGreaterThan(0) + } + // Windows binds RPC/SMB dual-stack, so both families must be represented. + expect(ports.some((port) => port.host.includes(':'))).toBe(true) + expect(ports.some((port) => !port.host.includes(':'))).toBe(true) + // Names come from the shared process table, never from a shell of our own. + expect(ports.some((port) => port.processName)).toBe(true) + // The table spells them `svchost.exe`; clients have always seen `svchost`. + expect(ports.every((port) => !port.processName?.endsWith('.exe'))).toBe(true) + }, 30_000) + + it('runs the de-escalated PowerShell fallback command line', async () => { + const result = await runProcess({ + program: windowsPowerShellPath(), + args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PORT_SCAN_SCRIPT], + timeoutMs: 20_000 + }) + + // No stderr assertion: an autoload or first-run banner writes there without + // the scan having failed. + expect(result.code).toBe(0) + expect(parseWindowsPowerShellPortRows(result.stdout).length).toBeGreaterThan(0) + }, 30_000) +}) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 44c1f398122..32b0af8662c 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { useSidebarResize } from '@/hooks/useSidebarResize' -import { ActivityScopeFilterChips } from './activity-scope-filter-controls' import { setActivityTerminalPortals, type ActivityTerminalPortalTarget @@ -341,7 +340,6 @@ export default function ActivityPrototypePage(): React.JSX.Element { canJumpToWorkspace={canJumpToWorkspace} isThreadListResizing={isThreadListResizing} onResizeStart={onResizeStart} - scopeFilterRow={} /> { let root: Root beforeEach(() => { + useAppStore.setState({ agentsVisibleHostIds: null, agentsFilterRepoIds: [] }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -57,6 +60,20 @@ describe('ActivityThreadOptionsMenu', () => { document.body.replaceChildren() }) + it('exposes an active persisted scope visually and in the trigger label', async () => { + useAppStore.setState({ agentsVisibleHostIds: ['local'] }) + + await act(async () => { + root.render() + }) + + const trigger = container.querySelector( + 'button[aria-label="Thread list options, filters active"]' + ) + expect(trigger).not.toBeNull() + expect(trigger?.querySelector('[data-scope-filter-dot]')).not.toBeNull() + }) + it('opens without recursively updating composed Radix trigger refs', async () => { await act(async () => { root.render() @@ -76,6 +93,49 @@ describe('ActivityThreadOptionsMenu', () => { expect(document.body.textContent).toContain('Compact mode') }) + it.each(['removed host', 'single project', 'stale project'] as const)( + 'resets a %s scope even when its filter menu is hidden', + async (scenario) => { + const originalState = useAppStore.getState() + const persist = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { set: persist } }) + useAppStore.setState({ + repos: scenario === 'single project' ? [makeRepo()] : [], + agentsVisibleHostIds: scenario === 'removed host' ? ['ssh:removed-host'] : null, + agentsFilterRepoIds: scenario === 'removed host' ? [] : ['repo-1'], + filterRepoIds: ['workspace-nav-filter'] + }) + try { + await act(async () => root.render()) + const trigger = container.querySelector( + 'button[aria-label="Thread list options, filters active"]' + ) + expect(trigger).not.toBeNull() + await act(async () => { + trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + expect(document.querySelector('[data-slot="dropdown-menu-sub-trigger"]')).toBeNull() + const reset = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === 'Show all hosts and projects' + ) + expect(reset).toBeDefined() + await act(async () => { + reset?.focus() + reset?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + expect(useAppStore.getState().agentsVisibleHostIds).toBeNull() + expect(useAppStore.getState().agentsFilterRepoIds).toEqual([]) + expect(useAppStore.getState().filterRepoIds).toEqual(['workspace-nav-filter']) + expect(persist).toHaveBeenCalledWith({ agentsVisibleHostIds: null }) + expect(persist).toHaveBeenCalledWith({ agentsFilterRepoIds: [] }) + expect(container.querySelector('[data-scope-filter-dot]')).toBeNull() + } finally { + act(() => useAppStore.setState(originalState)) + vi.unstubAllGlobals() + } + } + ) + it('renders group by options when provided', async () => { const onGroupByChange = vi.fn() await act(async () => { @@ -159,7 +219,7 @@ describe('ActivityThreadOptionsMenu', () => { expect(document.body.textContent).toContain('Show unread only') }) - it('explains show unread threads only on hover and shows unread dot when hasUnreadThreads is true', async () => { + it('explains show unread threads only on hover without a second unread state marker', async () => { const onToggleUnread = vi.fn() await act(async () => { root.render( @@ -191,7 +251,7 @@ describe('ActivityThreadOptionsMenu', () => { expect(document.body.textContent).toContain( 'Filters the activity list to show only threads with unread updates.' ) - expect(document.querySelector('[data-unread-dot]')).not.toBeNull() + expect(document.querySelector('[data-unread-dot]')).toBeNull() }) it('renders show child agents checkbox when onShowChildAgentsChange is provided', async () => { diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index 3723563d143..65152720567 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -55,6 +55,7 @@ vi.mock('sonner', () => ({ toast: toastSpy })) import { CLEAR_COMPLETED_EVICTION_FALLBACK_MS, + clearActivityThread, clearCompletedActivity, flushPendingClearCompletedEvictions, isClearableActivityThread, @@ -191,6 +192,28 @@ describe('clearCompletedActivity', () => { expect(drop).toHaveBeenCalledTimes(1) }) + it('clears one thread immediately without bulk-clear feedback', () => { + expect(clearActivityThread(doneThread)).toBe(true) + expect(mockStore.activityClearedAtByPaneKey).toEqual({ 't-done:1': 5_000 }) + expect(mockStore.dismissRetainedAgents).toHaveBeenCalledWith(['t-done:1']) + expect( + ( + window as unknown as { + api: { agentStatus: { dropPersistedBatch: ReturnType } } + } + ).api.agentStatus.dropPersistedBatch + ).toHaveBeenCalledWith([ + expect.objectContaining({ paneKey: 't-done:1', receivedAt: 5_000, stateStartedAt: 5_000 }) + ]) + expect(toastSpy).not.toHaveBeenCalled() + }) + + it('does not clear one active thread', () => { + expect(clearActivityThread(workingThread)).toBe(false) + expect(mockStore.applyActivityClearedAt).not.toHaveBeenCalled() + expect(mockStore.dismissRetainedAgents).not.toHaveBeenCalled() + }) + it('undo restores prior cutoffs and re-retains snapshots, and skips the disk drop', () => { mockStore.activityClearedAtByPaneKey = { 't-done:1': 1_111 } clearCompletedActivity([doneThread, interruptedThread]) diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts index 72eee3ea4af..79b69e96ea9 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.ts @@ -96,6 +96,19 @@ function evictPersistedStatuses(identities: readonly AgentStatusCacheIdentity[]) } } +/** Clear one completed thread immediately; bulk clearing owns the undo toast. */ +export function clearActivityThread(thread: AgentPaneThread): boolean { + const state = useAppStore.getState() + const plan = planClearCompletedActivity([thread], state) + if (plan.clearedThreadCount === 0) { + return false + } + state.applyActivityClearedAt(plan.cutoffPatch) + state.dismissRetainedAgents(plan.retainedSnapshots.map((retained) => retained.entry.paneKey)) + evictPersistedStatuses(plan.cacheIdentities) + return true +} + /** * Clear completed/interrupted activity threads with an undo window. * diff --git a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx index 7da07182062..a9e75e10483 100644 --- a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx +++ b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx @@ -1,7 +1,7 @@ -import React, { useMemo } from 'react' -import { X } from 'lucide-react' +import React from 'react' import { useAppStore } from '@/store' -import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' import SidebarRepositoryFilterSection from '@/components/sidebar/SidebarRepositoryFilterSection' import { SidebarHostScopeMenuSection } from '@/components/sidebar/SidebarHostScopeMenuSection' import { @@ -9,9 +9,6 @@ import { shouldShowHostScopeControls } from '@/components/sidebar/sidebar-host-options' import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options' -import type { SidebarHostOption } from '@/components/sidebar/sidebar-host-options' -import { getExecutionHostLabel, type ExecutionHostId } from '../../../../shared/execution-host' -import { translate } from '@/i18n/i18n' /** * Host/project scope controls for the Agents activity surfaces. State is the @@ -26,12 +23,26 @@ export function ActivityScopeFilterMenuSections(): React.JSX.Element | null { const setAgentsFilterRepoIds = useAppStore((s) => s.setAgentsFilterRepoIds) const { hostOptions } = useSidebarHostScopeOptions() const showHostScopeControls = shouldShowHostScopeControls(hostOptions) + const hasScopeFilter = agentsVisibleHostIds !== null || agentsFilterRepoIds.length > 0 - if (!showHostScopeControls && repos.length <= 1) { + if (!hasScopeFilter && !showHostScopeControls && repos.length <= 1) { return null } return ( <> + {hasScopeFilter ? ( + { + setAgentsVisibleHostIds(null) + setAgentsFilterRepoIds([]) + }} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.resetScope', + 'Show all hosts and projects' + )} + + ) : null} {showHostScopeControls ? ( host.id === id)?.label ?? getExecutionHostLabel(id) - } - return translate( - 'auto.components.sidebar.sidebarHostOptions.visibleHostsCount', - '{{value0}} hosts', - { value0: visibleHostIds.length } - ) -} - -function ScopeFilterChip({ - label, - clearLabel, - onClear -}: { - label: string - clearLabel: string - onClear: () => void -}): React.JSX.Element { - return ( - - {label} - - - ) -} - /** - * Dismissible chips naming the active persisted scope. - * Why always shown while a scope is active: the filter survives restarts, so an - * invisible one would silently hide running agents from a monitoring surface. - * - * Why the outer/inner split: this stays mounted on every activity surface, so - * while no scope is set it must subscribe only to the two filter fields — the - * host-registry derivation (settings, SSH/runtime status churn) lives in the - * inner row and mounts only for an active filter. + * Whether the persisted agents-view scope narrows the list. + * Why exported: the options-menu trigger shows a dot for an active scope, so a + * filter that survives restarts can't silently hide running agents. */ -export function ActivityScopeFilterChips(): React.JSX.Element | null { - const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) - const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) - if (agentsVisibleHostIds === null && agentsFilterRepoIds.length === 0) { - return null - } - return -} - -function ActiveScopeFilterChipsRow(): React.JSX.Element | null { - const repos = useAppStore((s) => s.repos) - const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) - const setAgentsVisibleHostIds = useAppStore((s) => s.setAgentsVisibleHostIds) - const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) - const setAgentsFilterRepoIds = useAppStore((s) => s.setAgentsFilterRepoIds) - const { hostOptions } = useSidebarHostScopeOptions() - - const selectedRepoNames = useMemo( - () => - repos.filter((repo) => agentsFilterRepoIds.includes(repo.id)).map((repo) => repo.displayName), - [repos, agentsFilterRepoIds] - ) - const hasHostFilter = agentsVisibleHostIds !== null - const hasRepoFilter = selectedRepoNames.length > 0 - // Why: a repo filter of only-stale ids renders nothing; the outer gate is a fast path, not the authority. - if (!hasHostFilter && !hasRepoFilter) { - return null - } - const repoLabel = - selectedRepoNames.length === 1 - ? selectedRepoNames[0] - : translate( - 'auto.components.sidebar.SidebarRepositoryFilterSection.selectedProjectsCount', - '{{value0}} projects', - { value0: selectedRepoNames.length } - ) - return ( -
- {agentsVisibleHostIds ? ( - setAgentsVisibleHostIds(null)} - /> - ) : null} - {hasRepoFilter ? ( - setAgentsFilterRepoIds([])} - /> - ) : null} -
+export function useActivityScopeFilterActive(): boolean { + return useAppStore( + (state) => state.agentsVisibleHostIds !== null || state.agentsFilterRepoIds.length > 0 ) } diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index 91375ec974b..07f50bce92d 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -98,7 +98,9 @@ describe('activity thread host routing', () => { // Bare setActiveWorktree skips setActiveView('terminal'), initial-terminal seeding and // sleeping-session resume — the workspace dispatcher is the only path that runs them. expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { - executionHostId: REMOTE_HOST + executionHostId: REMOTE_HOST, + revealInSidebar: false, + clearSidebarFilters: false }) expect(setActiveWorktree).not.toHaveBeenCalled() expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( @@ -121,7 +123,9 @@ describe('activity thread host routing', () => { expect(setSelectedPaneKey).toHaveBeenCalledWith(thread.paneKey) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { - executionHostId: REMOTE_HOST + executionHostId: REMOTE_HOST, + revealInSidebar: false, + clearSidebarFilters: false }) expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( thread.tab.id, @@ -136,7 +140,9 @@ describe('activity thread host routing', () => { makeActions().selectThread(thread) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { - executionHostId: REMOTE_HOST + executionHostId: REMOTE_HOST, + revealInSidebar: false, + clearSidebarFilters: false }) expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index f9f77587a1e..a940129f19c 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -83,7 +83,13 @@ export function createActivityThreadActions({ // state of an SSH session that was never revived — has no resident tab until // resumeSleepingAgentSessionsForWorktree/ensureWorktreeHasInitialTerminal run inside here. // Probing tab residency first is what made a remote row click a silent no-op (#16731). - if (activateAndRevealWorkspace(thread.worktree.id, { executionHostId }) === false) { + if ( + activateAndRevealWorkspace(thread.worktree.id, { + executionHostId, + revealInSidebar: false, + clearSidebarFilters: false + }) === false + ) { return } if ( diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx index 0e604a16098..ef1f109d50a 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx @@ -68,7 +68,8 @@ function Harness({ onSelect = vi.fn(), onJump = vi.fn(), onMarkRead = vi.fn(), - onMarkUnread = vi.fn() + onMarkUnread = vi.fn(), + onClear }: { thread: AgentPaneThread selected?: boolean @@ -76,6 +77,7 @@ function Harness({ onJump?: () => void onMarkRead?: () => void onMarkUnread?: () => void + onClear?: (thread: AgentPaneThread) => void }): ReactElement { return ( @@ -86,6 +88,7 @@ function Harness({ onJump={onJump} onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} + onClear={onClear} canJump={true} compactMode={false} /> @@ -148,6 +151,30 @@ describe('ActivityThreadHoverCard and ActivityThreadRow', () => { expect(onSelect).not.toHaveBeenCalled() }) + it('clears from a keyboard-reachable row action without selecting the row', async () => { + const thread = createTestThread() + const onClear = vi.fn() + const onSelect = vi.fn() + + await act(async () => { + root.render() + }) + + const clearButton = container.querySelector( + 'button[aria-label="Clear notification"]' + ) + expect(clearButton).not.toBeNull() + expect(clearButton?.className).toContain('focus-visible:opacity-100') + expect(clearButton?.className).toContain('can-hover:pointer-events-none') + expect(clearButton?.className).toContain('can-hover:group-hover:pointer-events-auto') + expect(clearButton?.className).not.toContain('invisible') + + act(() => clearButton?.click()) + + expect(onClear).toHaveBeenCalledWith(thread) + expect(onSelect).not.toHaveBeenCalled() + }) + it('renders hover card content with workspace info, host, task details, and notes', async () => { const thread = createTestThread({ worktree: { diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.tsx index 7879b486633..a5503e58562 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane.tsx @@ -74,7 +74,6 @@ export function ActivityThreadListPane({ showFilterControls = true, showOptionsMenu = true, showInlineActions = true, - scopeFilterRow, collapsedGroupKeys, onToggleGroupCollapse, scrollTopRef @@ -111,8 +110,6 @@ export function ActivityThreadListPane({ showFilterControls?: boolean showOptionsMenu?: boolean showInlineActions?: boolean - /** Rendered between the toolbar and the list; carries the active-scope chips row. */ - scopeFilterRow?: React.ReactNode collapsedGroupKeys?: ReadonlySet onToggleGroupCollapse?: (groupKey: string) => void /** Optional view-local scroll memory; updated without triggering React renders. */ @@ -318,7 +315,6 @@ export function ActivityThreadListPane({ showOptionsMenu={showOptionsMenu} showInlineActions={showInlineActions} /> - {scopeFilterRow}
void }): React.JSX.Element { const skipCloseAutoFocusRef = React.useRef(false) + const scopeFilterActive = useActivityScopeFilterActive() + const optionsLabel = scopeFilterActive + ? translate( + 'auto.components.activity.ActivityPrototypePage.threadListOptionsFiltered', + 'Thread list options, filters active' + ) + : translate('auto.components.activity.ActivityPrototypePage.db8a1878b5', 'Thread list options') return ( @@ -88,13 +98,17 @@ export function ActivityThreadOptionsMenu({ type="button" variant="ghost" size="icon-xs" - className="text-muted-foreground" - aria-label={translate( - 'auto.components.activity.ActivityPrototypePage.db8a1878b5', - 'Thread list options' - )} + className="relative text-muted-foreground" + aria-label={optionsLabel} > + {scopeFilterActive ? ( + @@ -149,13 +163,6 @@ export function ActivityThreadOptionsMenu({ 'Show unread only' )} - {hasUnreadThreads ? ( -
} + /> + ) + fireEvent.click(screen.getByLabelText(/^Ship the thing — /)) + expect(onOpenDialog).toHaveBeenCalledTimes(1) + expect(onOpenDialog.mock.calls[0]?.[0]).toMatchObject({ id: 'PVTI_1' }) + }) + + it('places items from row-carried dates when the view hides its date fields', () => { + render( + list
} + /> + ) + expect(screen.getByText('Placed by Start date → Target date')).toBeTruthy() + expect(screen.getByLabelText(/^Hidden-field item — /)).toBeTruthy() + expect(screen.queryByText('list')).toBeNull() + }) + + it('announces restricted items by name in the bar label', () => { + const redacted: GitHubProjectRow = { + ...row('PVTI_9', '', [ + { kind: 'date', fieldId: 'f_start', date: '2026-03-02' }, + { kind: 'date', fieldId: 'f_end', date: '2026-03-05' } + ]), + itemType: 'REDACTED' + } + render( + list
} + /> + ) + expect(screen.getByLabelText(/^Restricted item — /)).toBeTruthy() + }) + + it('falls back to the caller-supplied list when no field can place items', () => { + render(list
} />) + expect(screen.getByText('list')).toBeTruthy() + expect( + screen.getByText( + 'This roadmap view has no date or iteration field to place items on, so Orca is listing them instead.' + ) + ).toBeTruthy() + }) + + it('reports an empty filter result instead of drawing an empty grid', () => { + render( + list} + /> + ) + expect(screen.getByText("No items match this view's filter.")).toBeTruthy() + expect(screen.queryByText('list')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/github-project/ProjectRoadmap.tsx b/src/renderer/src/components/github-project/ProjectRoadmap.tsx new file mode 100644 index 00000000000..d06ef409dff --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectRoadmap.tsx @@ -0,0 +1,419 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { CalendarClock } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' +import { cn } from '@/lib/utils' +import { i18n, translate } from '@/i18n/i18n' +import ProjectGroupHeader from './ProjectGroupHeader' +import ProjectRoadmapBar from './ProjectRoadmapBar' +import { ProjectTitleCell } from './ProjectCellIdentity' +import { formatRoadmapTick } from './roadmap-tick-format' +import { loadRoadmapZoom, saveRoadmapZoom } from './roadmap-zoom-preference' +import { groupRows, sortRows } from '../../../../shared/github/project-group-sort' +import { + buildRoadmapTicks, + getRoadmapSpan, + resolveRoadmapDateSource, + roadmapOffsetPx, + roadmapSourceFieldNames, + type RoadmapSpan, + type RoadmapTick, + type RoadmapZoom +} from '../../../../shared/github/project-roadmap-timeline' +import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github/project-types' + +const LABEL_WIDTH_PX = 280 +const LANE_HEIGHT_PX = 36 +const TICK_WIDTH_PX: Record = { month: 148, quarter: 128, year: 160 } +const ZOOMS: RoadmapZoom[] = ['month', 'quarter', 'year'] + +function localTodayAsUtcMidnightMs(): number { + const now = new Date() + return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) +} + +type Props = { + table: GitHubProjectTable + onOpenDialog?: (row: GitHubProjectRow) => void + /** Rendered instead of the timeline when the view has no field to place + * items on — the caller supplies the table list so the items stay usable. */ + fallback: React.ReactNode +} + +export default function ProjectRoadmap({ + table, + onOpenDialog, + fallback +}: Props): React.JSX.Element { + const view = table.selectedView + const prefersReducedMotion = usePrefersReducedMotion() + const locale = i18n.resolvedLanguage ?? i18n.language + // Why: the grid lives on UTC calendar days (parseRoadmapDate), so "today" + // must be the viewer's LOCAL calendar date mapped to UTC midnight — the raw + // instant would shift the marker into the wrong day off UTC. + const [todayMs, setTodayMs] = useState(localTodayAsUtcMidnightMs) + // Why: a pane left open across midnight would otherwise keep yesterday's + // marker; re-arm after each fire so multi-day sessions stay honest. + useEffect(() => { + const now = new Date() + const nextLocalMidnight = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + 1 + ).getTime() + // Why: the +1s pad absorbs timer drift so the callback lands after the + // date change, not just before it. + const timer = setTimeout( + () => setTodayMs(localTodayAsUtcMidnightMs()), + nextLocalMidnight - now.getTime() + 1000 + ) + return () => clearTimeout(timer) + }, [todayMs]) + const [zoom, setZoom] = useState(loadRoadmapZoom) + const [collapsed, setCollapsed] = useState>(() => new Set()) + const scrollRef = useRef(null) + + const source = useMemo(() => resolveRoadmapDateSource(view, table.rows), [view, table.rows]) + const groups = useMemo(() => groupRows(table, sortRows(table, table.rows)), [table]) + const spans = useMemo(() => { + const bySpan = new Map() + if (!source) { + return bySpan + } + for (const row of table.rows) { + const span = getRoadmapSpan(row, source) + if (span) { + bySpan.set(row.id, span) + } + } + return bySpan + }, [source, table.rows]) + + const tickWidth = TICK_WIDTH_PX[zoom] + const ticks = useMemo( + () => buildRoadmapTicks(Array.from(spans.values()), zoom, todayMs), + [spans, todayMs, zoom] + ) + const timelineWidth = ticks.length * tickWidth + const todayPx = roadmapOffsetPx(todayMs, ticks, tickWidth) + const hasTimeline = source !== null && table.rows.length > 0 + + // Why: the interesting part of a roadmap is around now — open there instead + // of at the padded left edge, and re-centre when the zoom changes scale. + const scrollToToday = useCallback(() => { + const scroller = scrollRef.current + if (!scroller) { + return + } + const lead = (scroller.clientWidth - LABEL_WIDTH_PX) / 3 + scroller.scrollTo({ + left: Math.max(0, todayPx - lead), + behavior: prefersReducedMotion ? 'instant' : 'smooth' + }) + }, [todayPx, prefersReducedMotion]) + const todayPxRef = useRef(todayPx) + useLayoutEffect(() => { + todayPxRef.current = todayPx + }) + // Center when the timeline appears or zoom changes; refetches must preserve user scroll. + useEffect(() => { + const scroller = scrollRef.current + if (!scroller) { + return + } + const lead = (scroller.clientWidth - LABEL_WIDTH_PX) / 3 + scroller.scrollLeft = Math.max(0, todayPxRef.current - lead) + }, [zoom, hasTimeline]) + + const colorFieldId = useMemo(() => { + const grouped = view.groupByFields.find((field) => field.kind === 'single-select') + return (grouped ?? view.fields.find((field) => field.kind === 'single-select'))?.id ?? null + }, [view]) + + if (!source) { + return ( +
+
+ {translate( + 'auto.components.github.project.ProjectRoadmap.be52f7b6db', + 'This roadmap view has no date or iteration field to place items on, so Orca is listing them instead.' + )} +
+ {fallback} +
+ ) + } + + if (table.rows.length === 0) { + return ( +
+ {translate( + 'auto.components.github.project.ProjectViewList.4f57d2e0b1', + "No items match this view's filter." + )} +
+ ) + } + + const undatedCount = table.rows.length - spans.size + const bandWidth = LABEL_WIDTH_PX + timelineWidth + return ( +
+ { + setZoom(next) + saveRoadmapZoom(next) + }} + onToday={scrollToToday} + /> +
+
+ +
+
+
+ {groups.map((group) => { + const expanded = !collapsed.has(group.key) + return ( +
+ {view.groupByFields[0] ? ( + + setCollapsed((previous) => { + const next = new Set(previous) + if (!next.delete(group.key)) { + next.add(group.key) + } + return next + }) + } + /> + ) : null} + {expanded + ? group.rows.map((row) => ( + + )) + : null} +
+ ) + })} +
+
+
+
+ ) +} + +function RoadmapControls({ + placedBy, + undatedCount, + zoom, + onZoom, + onToday +}: { + placedBy: string + undatedCount: number + zoom: RoadmapZoom + onZoom: (zoom: RoadmapZoom) => void + onToday: () => void +}): React.JSX.Element { + const zoomLabels: Record = { + month: translate('auto.components.github.project.ProjectRoadmap.6405e036e0', 'Month'), + quarter: translate('auto.components.github.project.ProjectRoadmap.f2b1cabef7', 'Quarter'), + year: translate('auto.components.github.project.ProjectRoadmap.b6afc6fe45', 'Year') + } + return ( +
+ + {translate( + 'auto.components.github.project.ProjectRoadmap.343888b143', + 'Placed by {{value0}}', + { + value0: placedBy + } + )} + + {undatedCount > 0 ? ( + + {translate( + 'auto.components.github.project.ProjectRoadmap.6a088a5da1', + '{{value0}} without dates', + { value0: undatedCount } + )} + + ) : null} +
+ +
+ {ZOOMS.map((option) => ( + + ))} +
+
+
+ ) +} + +function RoadmapHeaderRow({ + ticks, + tickWidth, + zoom, + locale, + todayPx +}: { + ticks: RoadmapTick[] + tickWidth: number + zoom: RoadmapZoom + locale: string + todayPx: number +}): React.JSX.Element { + return ( +
+
+ {translate('auto.components.github.project.ProjectRoadmap.e304235879', 'Item')} +
+
+ {ticks.map((tick, index) => { + const { label, sublabel } = formatRoadmapTick(tick, zoom, index, locale) + return ( +
+ {label} + {sublabel ? {sublabel} : null} +
+ ) + })} +
+
+
+ ) +} + +function RoadmapLane({ + row, + span, + ticks, + tickWidth, + timelineWidth, + colorFieldId, + locale, + onOpenDialog +}: { + row: GitHubProjectRow + span: RoadmapSpan | null + ticks: RoadmapTick[] + tickWidth: number + timelineWidth: number + colorFieldId: string | null + locale: string + onOpenDialog?: (row: GitHubProjectRow) => void +}): React.JSX.Element { + const statusValue = colorFieldId ? row.fieldValuesByFieldId[colorFieldId] : undefined + const chipColor = statusValue?.kind === 'single-select' ? statusValue.color : null + const left = span ? roadmapOffsetPx(span.startMs, ticks, tickWidth) : 0 + const width = span ? roadmapOffsetPx(span.endMs, ticks, tickWidth) - left : 0 + return ( +
+
+ onOpenDialog?.(row)} /> + {span ? null : ( + + {translate('auto.components.github.project.ProjectRoadmap.e077c79083', 'No dates')} + + )} +
+
+ {span ? ( + onOpenDialog?.(row)} + /> + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectRoadmapBar.tsx b/src/renderer/src/components/github-project/ProjectRoadmapBar.tsx new file mode 100644 index 00000000000..361378c0710 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectRoadmapBar.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { GitPullRequest, Lock } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { chipStyle, labelChipColors, singleSelectChipColors } from './project-cell-chip-colors' +import { formatRoadmapSpan } from './roadmap-tick-format' +import type { RoadmapSpan } from '../../../../shared/github/project-roadmap-timeline' +import type { GitHubProjectRow } from '../../../../shared/github/project-types' + +const MIN_BAR_WIDTH_PX = 24 + +type Props = { + row: GitHubProjectRow + span: RoadmapSpan + leftPx: number + widthPx: number + /** GitHub single-select color token for the row's status, when it has one. */ + chipColor: string | null + locale: string + onOpen?: () => void +} + +export default function ProjectRoadmapBar({ + row, + span, + leftPx, + widthPx, + chipColor, + locale, + onOpen +}: Props): React.JSX.Element { + const colors = chipColor ? singleSelectChipColors(chipColor) : labelChipColors('') + const interactive = row.itemType !== 'REDACTED' && row.itemType !== 'DRAFT_ISSUE' + const dates = formatRoadmapSpan(span, locale) + // Why: shared by the visible text, aria-label, and tooltip — a redacted row + // must never announce or render an empty name. + const title = + row.itemType === 'REDACTED' + ? translate('auto.components.github.project.ProjectRoadmapBar.7d1220d979', 'Restricted item') + : row.content.title + const bar = ( + + ) + return ( + + {bar} + +
+
{title}
+
{dates}
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectViewStates.tsx b/src/renderer/src/components/github-project/ProjectViewStates.tsx index 83e088b2759..e3184878851 100644 --- a/src/renderer/src/components/github-project/ProjectViewStates.tsx +++ b/src/renderer/src/components/github-project/ProjectViewStates.tsx @@ -42,13 +42,17 @@ function ProjectViewTab({ active: boolean onPick: (viewId: string) => void }): React.JSX.Element { - const supported = view.layout === 'TABLE_LAYOUT' + // Why: allowlist, not denylist — raw.layout is cast unchecked, so a future + // GitHub layout value must stay disabled instead of masquerading as a table. + const supported = view.layout === 'TABLE_LAYOUT' || view.layout === 'ROADMAP_LAYOUT' const layoutLabel = view.layout === 'BOARD_LAYOUT' ? 'Board' : view.layout === 'ROADMAP_LAYOUT' ? 'Roadmap' - : 'Table' + : view.layout === 'TABLE_LAYOUT' + ? 'Table' + : formatUnknownLayout(view.layout) const Icon = view.layout === 'BOARD_LAYOUT' ? KanbanSquare @@ -106,8 +110,8 @@ function ProjectViewTab({

{message}{' '} {translate( - 'auto.components.github.project.ProjectViewWrapper.1bf8c01c8b', - 'Switch to a Table view to work with this project in Orca.' + 'auto.components.github.project.ProjectViewStates.ac83c45672', + 'Switch to a Table or Roadmap view to work with this project in Orca.' )}

-
{expanded ? (
- {props.tasks.map((task) => ( -
  • -
  • - ))} + {props.tasks.map((task) => { + const label = backgroundTaskLabel(task) + return ( +
  • +
  • + ) + })} ) : (

    @@ -100,6 +115,23 @@ export function NativeChatBackgroundTasksStatus(props: { )}

    )} + {!props.supportsTaskStop ? ( +
    0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> + +
    + ) : null}
    ) : null}
    diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index afdf5ace7c5..2b4a9686aaf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -29,8 +29,9 @@ const mocks = vi.hoisted(() => ({ pasteFromClipboard: vi.fn(), submissions: [] as unknown[], monitoringBackgroundTasks: false, + supportsBackgroundTaskStop: false, backgroundTasks: [] as AgentSessionBackgroundTask[], - stopBackgroundTasks: vi.fn() + stopBackgroundTask: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -75,10 +76,11 @@ vi.mock('./use-structured-agent-session', async () => { retry: outbox.retry, isWorking: false, isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks, + supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop, backgroundTasks: mocks.backgroundTasks, turnId: null, cancel: vi.fn(), - stopBackgroundTasks: mocks.stopBackgroundTasks, + stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, optionSnapshot: [ { @@ -166,7 +168,8 @@ describe('NativeChatStructuredSession', () => { mocks.pasteFromClipboard.mockReset() mocks.submissions = [] mocks.monitoringBackgroundTasks = false - mocks.stopBackgroundTasks.mockReset() + mocks.supportsBackgroundTaskStop = false + mocks.stopBackgroundTask.mockReset() mocks.backgroundTasks = [] }) @@ -225,11 +228,12 @@ describe('NativeChatStructuredSession', () => { it('places background monitoring above the usable composer and stops without an active turn', async () => { mocks.monitoringBackgroundTasks = true + mocks.supportsBackgroundTaskStop = true mocks.backgroundTasks = [ { id: 'task-command', kind: 'command', description: 'sleep 180' }, { id: 'task-agent', kind: 'agent' } ] - mocks.stopBackgroundTasks.mockResolvedValue({ cancelled: true }) + mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true }) render( { expect(status.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(mocks.composerProps?.isWorking).toBe(false) expect(screen.queryByRole('list', { name: 'Running background tasks' })).toBeNull() + expect(screen.queryByRole('button', { name: /^Stop / })).toBeNull() const disclosure = screen.getByRole('button', { name: 'Monitoring background tasks' }) expect(disclosure.getAttribute('aria-expanded')).toBe('false') @@ -260,8 +265,130 @@ describe('NativeChatStructuredSession', () => { expect(screen.getByText('sleep 180')).toBeTruthy() expect(screen.getByText('Background agent')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Stop' })) - await waitFor(() => expect(mocks.stopBackgroundTasks).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: 'Stop sleep 180' })) + await waitFor(() => + expect(mocks.stopBackgroundTask).toHaveBeenCalledWith('session-background', 'task-command') + ) + }) + + it('tracks concurrent task stops independently and clears each pending result', async () => { + mocks.monitoringBackgroundTasks = true + mocks.supportsBackgroundTaskStop = true + mocks.backgroundTasks = [ + { id: 'task-one', kind: 'command', description: 'First task' }, + { id: 'task-two', kind: 'command', description: 'Second task' } + ] + let finishFirst!: (value: unknown) => void + let finishSecond!: (value: unknown) => void + mocks.stopBackgroundTask.mockImplementation( + (_sessionId: string, taskId: string) => + new Promise((resolve) => { + if (taskId === 'task-one') { + finishFirst = resolve + } else { + finishSecond = resolve + } + }) + ) + + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' })) + const firstStop = screen.getByRole('button', { name: 'Stop First task' }) + const secondStop = screen.getByRole('button', { name: 'Stop Second task' }) + + fireEvent.click(firstStop) + fireEvent.click(secondStop) + expect((firstStop as HTMLButtonElement).disabled).toBe(true) + expect((secondStop as HTMLButtonElement).disabled).toBe(true) + + await act(async () => finishFirst({ cancelled: true })) + await waitFor(() => expect((firstStop as HTMLButtonElement).disabled).toBe(false)) + expect((secondStop as HTMLButtonElement).disabled).toBe(true) + + await act(async () => finishSecond(null)) + await waitFor(() => expect((secondStop as HTMLButtonElement).disabled).toBe(false)) + }) + + it('keeps a stale session stop result from clearing the current session pending state', async () => { + mocks.monitoringBackgroundTasks = true + mocks.supportsBackgroundTaskStop = true + mocks.backgroundTasks = [{ id: 'task-one', kind: 'command', description: 'Shared task' }] + let finishOld!: (value: unknown) => void + let finishCurrent!: (value: unknown) => void + mocks.stopBackgroundTask.mockImplementation( + (sessionId: string) => + new Promise((resolve) => { + if (sessionId === 'session-old') { + finishOld = resolve + } else { + finishCurrent = resolve + } + }) + ) + const { rerender } = render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' })) + fireEvent.click(screen.getByRole('button', { name: 'Stop Shared task' })) + + rerender( + + ) + const currentStop = screen.getByRole('button', { name: 'Stop Shared task' }) + expect((currentStop as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(currentStop) + expect((currentStop as HTMLButtonElement).disabled).toBe(true) + + await act(async () => finishOld({ cancelled: true })) + expect((currentStop as HTMLButtonElement).disabled).toBe(true) + await act(async () => finishCurrent({ cancelled: true })) + await waitFor(() => expect((currentStop as HTMLButtonElement).disabled).toBe(false)) + }) + + it('keeps the expanded all-task stop fallback for a taskless older host', async () => { + mocks.monitoringBackgroundTasks = true + mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true }) + + render( + + ) + expect(screen.queryByRole('button', { name: 'Stop background tasks' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' })) + expect(screen.getByText('Task details are unavailable for this session.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Stop background tasks' })) + + await waitFor(() => + expect(mocks.stopBackgroundTask).toHaveBeenCalledWith( + 'session-taskless-background', + undefined + ) + ) }) it('routes a bare model command to the native option picker', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 9ac354f8a73..87adb0bda73 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -22,6 +22,14 @@ import { useStructuredNativeChatPaneCommands } from './use-structured-native-cha import type { NativeChatStructuredViewProps } from './native-chat-view-types' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' +type StoppingBackgroundTasks = { + sessionId: string + taskIds: ReadonlySet + all: boolean +} + +const NO_STOPPING_TASKS: ReadonlySet = new Set() + function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` } @@ -31,7 +39,8 @@ export function NativeChatStructuredSession( ): React.JSX.Element { const controller = useStructuredAgentSession(props) const [composerError, setComposerError] = useState(null) - const [stoppingBackgroundTasks, setStoppingBackgroundTasks] = useState(false) + const [stoppingBackgroundTasks, setStoppingBackgroundTasks] = + useState(null) const [optionPickerRequest, setOptionPickerRequest] = useState<{ id: string sequence: number @@ -83,6 +92,8 @@ export function NativeChatStructuredSession( const fileLinkContext = useNativeChatFileLinkContext(props.tabId) const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const fileLinkClick = useNativeChatFileLinkClick(fileLinkContext) + const activeStoppingBackgroundTasks = + stoppingBackgroundTasks?.sessionId === props.sessionId ? stoppingBackgroundTasks : null const prompt = controller.prompts[0] ?? null const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = @@ -277,10 +288,39 @@ export function NativeChatStructuredSession( {controller.isMonitoringBackgroundTasks ? ( { - setStoppingBackgroundTasks(true) - void controller.stopBackgroundTasks().finally(() => setStoppingBackgroundTasks(false)) + supportsTaskStop={controller.supportsBackgroundTaskStop} + stoppingTaskIds={activeStoppingBackgroundTasks?.taskIds ?? NO_STOPPING_TASKS} + stoppingAll={activeStoppingBackgroundTasks?.all ?? false} + onStop={(taskId) => { + const targetSessionId = props.sessionId + setStoppingBackgroundTasks((current) => { + const taskIds = new Set( + current?.sessionId === targetSessionId ? current.taskIds : NO_STOPPING_TASKS + ) + if (taskId) { + taskIds.add(taskId) + } + return { + sessionId: targetSessionId, + taskIds, + all: taskId ? current?.sessionId === targetSessionId && current.all : true + } + }) + void controller.stopBackgroundTask(taskId).finally(() => { + setStoppingBackgroundTasks((current) => { + if (current?.sessionId !== targetSessionId) { + return current + } + const taskIds = new Set(current.taskIds) + if (taskId) { + taskIds.delete(taskId) + } + const all = taskId ? current.all : false + return taskIds.size === 0 && !all + ? null + : { sessionId: targetSessionId, taskIds, all } + }) + }) }} /> ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx new file mode 100644 index 00000000000..de18b89bae3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx @@ -0,0 +1,88 @@ +import { + Bot, + Eye, + Folder, + Globe, + ListChecks, + Pencil, + Plug, + Search, + SquareTerminal, + Wrench +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + nativeChatToolIconName, + type NativeChatToolIconName +} from '../../../../shared/native-chat-tool-icon' + +/** Glyph name to component. */ +const NATIVE_CHAT_TOOL_GLYPHS: Record = { + eye: Eye, + search: Search, + folder: Folder, + 'square-terminal': SquareTerminal, + pencil: Pencil, + globe: Globe, + plug: Plug, + bot: Bot, + 'list-checks': ListChecks, + wrench: Wrench +} + +/** The fixed 16px slot with a 14px glyph, which keeps every row left-aligned + * including rows whose category this vocabulary doesn't model. */ +function NativeChatGlyphSlot({ + glyph: Glyph, + className +}: { + glyph: LucideIcon + className?: string +}): React.JSX.Element { + return ( + + + + ) +} + +/** + * The category glyph on a tool row. Decorative — the word beside it is the + * accessible name — so it is `aria-hidden` and must never render without that + * word. + * + * A running run header names one call and so resolves its glyph through this + * same component, and can never disagree with the row it names. + */ +export function NativeChatToolIcon({ + rowWord, + className +}: { + /** The word the row renders, which is the row's whole identity. */ + rowWord: string + className?: string +}): React.JSX.Element { + return ( + + ) +} + +/** + * The glyph over a settled run, which speaks for every call in it rather than + * for one row, so its caller resolves the category and no row word names it. + * Same table and same slot as a row's glyph, so the two can never draw one + * category differently. + */ +export function NativeChatToolRunIcon({ + iconName, + className +}: { + iconName: NativeChatToolIconName + className?: string +}): React.JSX.Element { + return +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index 050b3f7c84b..e19c203ee5c 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -11,6 +11,18 @@ import { NativeChatToolRun } from './NativeChatToolRun' afterEach(cleanup) +/** The first glyph of every row — the run header, then each tool line. Named by + * lucide's own class, so an icon that swaps shows up as a different name. */ +function leadingGlyphs(container: HTMLElement): (string | null)[] { + return [...container.querySelectorAll('button')].map( + (button) => + button + .querySelector('svg') + ?.getAttribute('class') + ?.match(/lucide-[a-z0-9-]+/)?.[0] ?? null + ) +} + describe('NativeChatToolRun', () => { it('uses the shared clean label for a desktop tool row', () => { const blocks: NativeChatBlock[] = [ @@ -355,4 +367,234 @@ describe('NativeChatToolRun', () => { expect(container.querySelector('.lucide-check')).toBeInTheDocument() expect(container.querySelector('.lucide-circle-alert')).toBeNull() }) + + it('shows the category glyph beside the word a classified row is named by', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'read', + input: { command: "sed -n '1,200p' notes.txt", path: 'notes.txt' }, + state: 'completed' + } + ] + + const { container } = render() + + const glyph = container.querySelector('.lucide-eye') + expect(glyph).toBeInTheDocument() + expect(glyph).toHaveAttribute('aria-hidden') + expect(screen.getByText('read')).toBeInTheDocument() + }) + + it('holds one glyph for a category across running, completed, and failed', () => { + const searchCall = (state: 'running' | 'completed' | 'failed'): NativeChatBlock[] => [ + { type: 'tool-call', name: 'search', input: { query: 'beta' }, state } + ] + const { container, rerender } = render( + + ) + + expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search']) + + for (const settled of ['completed', 'failed'] as const) { + rerender( + + ) + + // A leading check here would read as the row changing identity on settle. + expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search']) + } + }) + + it('falls back to the generic tool glyph, not the terminal, for an unmodelled row', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { prompt: 'which?' }, + state: 'completed' + } + ] + + const { container } = render() + + // A terminal here would assert a shell ran when nothing says one did. + expect(container.querySelector('.lucide-square-terminal')).toBeNull() + expect(container.querySelector('.lucide-wrench')).toBeInTheDocument() + }) + + it('agrees between the header and the row it names for an unmodelled tool', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { prompt: 'which?' }, + state: 'completed' + } + ] + + const { container } = render() + + // Header and row read the same function, so one run cannot show two glyphs. + expect(leadingGlyphs(container)).toEqual(['lucide-wrench', 'lucide-wrench']) + }) + + it('leaves a result row without a category glyph, its word being translated copy', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'read', input: { path: 'notes.txt' }, state: 'completed' }, + { type: 'tool-result', output: 'first line' } + ] + + render() + + const resultRow = screen.getByText('Result').closest('button') + // Keying a category off 'Result' would resolve a different glyph per locale. + expect( + [...(resultRow?.querySelectorAll('svg') ?? [])].map( + (svg) => svg.getAttribute('class')?.match(/lucide-[a-z0-9-]+/)?.[0] + ) + ).toEqual(['lucide-chevron-right']) + }) + + it('heads a projected diff run with the file-change glyph, not the generic one', () => { + const projected = projectStructuredItemToNativeChat({ + itemId: 'file-change', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'diff', + path: 'src/a.ts', + patch: { + head: '@@ -1 +1 @@\n-was\n+now', + truncated: false, + byteLength: 24, + digest: 'a'.repeat(64) + } + } + }) + + const { container } = render( + + ) + + // The run renders an edited-file card, so a wrench above it reads as a tool + // this vocabulary does not model. + expect(container.querySelector('.lucide-pencil')).toBeInTheDocument() + expect(container.querySelector('.lucide-wrench')).toBeNull() + }) + + describe('the settled header glyph over a whole run', () => { + // The header's text summarizes the run's first calls, so its glyph has to + // describe the same run rather than whichever call happened to finish last. + const call = (name: string, input: unknown): NativeChatBlock => ({ + type: 'tool-call', + name, + input, + state: 'completed' + }) + + it('heads a run that is all reads with the read glyph', () => { + const blocks: NativeChatBlock[] = [ + call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }), + call('read', { command: "sed -n '1,50p' b.ts", path: 'b.ts' }) + ] + + const { container } = render( + + ) + + expect(leadingGlyphs(container)).toEqual(['lucide-eye', 'lucide-eye', 'lucide-eye']) + }) + + it('heads a run that is all shell with the terminal glyph, whatever each is named', () => { + const blocks: NativeChatBlock[] = [ + call('shell', { command: 'npm test' }), + call('Bash', { command: 'git status' }) + ] + + const { container } = render( + + ) + + expect(leadingGlyphs(container)).toEqual([ + 'lucide-square-terminal', + 'lucide-square-terminal', + 'lucide-square-terminal' + ]) + }) + + it('heads a run spanning categories with the generic tool glyph', () => { + const blocks: NativeChatBlock[] = [ + call('shell', { command: 'npm test' }), + call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }) + ] + + const { container } = render( + + ) + + // An eye here — the last call's glyph — would claim a category the summary + // beside it does not describe. + expect(leadingGlyphs(container)).toEqual([ + 'lucide-wrench', + 'lucide-square-terminal', + 'lucide-eye' + ]) + }) + + it('heads a single-call run with that call\u2019s own glyph', () => { + const { container } = render( + + ) + + expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search']) + }) + + it('leaves a run with no tool calls headed by no category glyph', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-result', output: 'first line' }] + + const { container } = render( + + ) + + // Only the trailing check and the chevron; a wrench here would claim a + // tool category for a run holding no tool call. + expect(leadingGlyphs(container)).toEqual(['lucide-check', 'lucide-chevron-right']) + }) + + it('keeps naming the active call while the run is still running', () => { + const blocks: NativeChatBlock[] = [ + call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }), + { type: 'tool-call', name: 'shell', input: { command: 'npm test' }, state: 'running' } + ] + + const { container } = render( + + ) + + // The running header names one call, so its glyph is that call's. + expect(leadingGlyphs(container)[0]).toBe('lucide-square-terminal') + }) + }) + + it('labels a bare list row by the command it ran rather than an invented path', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'list', + input: { command: 'ls', cwd: '/repo' }, + state: 'completed' + } + ] + + const { container } = render() + + expect(container.querySelector('.lucide-folder')).toBeInTheDocument() + expect(screen.getByTitle('ls')).toHaveTextContent('ls') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index e91177b375a..faaab338c66 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Check, ChevronRight, SquareTerminal, Wrench } from 'lucide-react' +import { Check, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { @@ -23,11 +23,12 @@ import { } from './native-chat-tool-summary' import { describeActiveToolCall, - isCommandToolName, NATIVE_CHAT_TOOL_ACTIVITY_COPY, selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' +import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' import { NativeChatDiffView } from './NativeChatDiffView' +import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon' function activeToolLabel(call: Extract): string { const { key, toolName, preview } = describeActiveToolCall(call) @@ -60,8 +61,9 @@ function ToolLine({ let body: { output: string; isError?: boolean } | null = null let detail: string | null = null let inputHasDetail = false + const isCall = isToolCallBlock(block) - if (isToolCallBlock(block)) { + if (isCall) { name = block.name const inputDisplay = createToolInputDisplay(block.input) preview = inputDisplay.label @@ -90,6 +92,14 @@ function ToolLine({ )} aria-expanded={hasDetail ? expanded : undefined} > + {isCall ? ( + /* Decorative category glyph; the word beside it is the row's name. */ + + ) : ( + /* A result's word is translated copy, not a tool name, so there is no + category to read from it. The empty slot keeps rows aligned. */ + + )} {name} @@ -217,8 +227,13 @@ export function NativeChatToolRun({ () => (open ? buildEditCards(blocks) : NO_EDIT_CARDS), [open, blocks] ) - const ActiveToolIcon = - latestActiveCall && isCommandToolName(latestActiveCall.name) ? SquareTerminal : Wrench + // Only the settled header reads this. It stands over `summary`, which speaks + // for the run's first calls rather than its last, so a glyph taken from one + // call would assert a category the text beside it doesn't describe. A run that + // spans categories therefore heads with the generic tool glyph. The glyph is + // fixed once settled, so state rides on the trailing mark — a leading glyph + // that flipped to a check would read as a change of identity. + const settledHeaderIcon = nativeChatToolRunIconName(blocks.filter(isToolCallBlock)) const fallbackLabel = callCount === 1 ? translate('components.native-chat.tool.countOne', NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne) @@ -250,9 +265,7 @@ export function NativeChatToolRun({ aria-expanded={open} aria-live="polite" > - - - + {activeToolLabel(latestActiveCall)} @@ -265,10 +278,8 @@ export function NativeChatToolRun({ className="group flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left" aria-expanded={open} > - {structuredActivityUi ? ( - - - + {structuredActivityUi && settledHeaderIcon ? ( + ) : null} {callCount}× @@ -276,6 +287,10 @@ export function NativeChatToolRun({ {summary || fallbackLabel} + {/* Completion reads as a trailing mark so the leading glyph can stay fixed. */} + {structuredActivityUi ? ( + + ) : null} {/* Chevron is revealed on hover when collapsed and points down when open. */} ({ - call: vi.fn(), removeAgentStatus: vi.fn(), setAgentStatus: vi.fn(), store: null as null | { getState: () => Record setState: (state: Record) => void }, - subscribe: vi.fn(), + subscribeStatus: vi.fn(), + subscribeTranscript: vi.fn(), + supportsCapability: vi.fn(), unsubscribe: vi.fn() })) @@ -73,17 +79,22 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ state.testRuntimeOwner ?? null })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - subscribeStructuredAgentSession: mocks.subscribe + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: mocks.subscribeTranscript, + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus })) import { getStructuredAgentSessionTabs, StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' -import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' -import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' const structuredTab = { id: 'structured-tab-1', @@ -100,60 +111,40 @@ const structuredTab = { agentSessionAgent: 'codex' } satisfies Tab -const userItem = { - itemId: 'item-1', - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } -} as const +const providerSession = { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' } as const -const historyResult = { - ok: true, - providerSession: { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' }, - page: { +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { sessionId: 'session-1', - epoch: 'epoch-1', - fence: 1, - direction: 'tail', - items: [userItem], - removedItemIds: [], - submissions: [], - window: { - oldest: { epoch: 'epoch-1', sequence: 1 }, - newest: { epoch: 'epoch-1', sequence: 1 }, - nextCursor: { epoch: 'epoch-1', sequence: 1 } - }, - liveCursor: { epoch: 'epoch-1', sequence: 1 }, - hasOlder: false, - hasNewer: false + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + latestPrompt: 'hello', + providerSession, + updatedAt: 1, + ...overrides } } -function ActiveSessionRead(): null { - useStructuredAgentSessionRead({ - sessionId: structuredTab.entityId, - target: { kind: 'local' }, - isVisible: true - }) - return null +function statuses(): Record[] { + return Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) } -function ActiveComposition(): React.JSX.Element { - return ( - <> - - - - ) +/** The host side of the most recent status subscription. */ +function feed(index = 0): { target: unknown; emit: (event: AgentSessionStatusEvent) => void } { + const call = mocks.subscribeStatus.mock.calls[index] + if (!call) { + throw new Error('status feed not subscribed') + } + return { target: call[0], emit: call[1] as (event: AgentSessionStatusEvent) => void } } describe('StructuredAgentSessionStatusBridge', () => { beforeEach(() => { vi.clearAllMocks() - resetStructuredAgentSessionReadOwnersForTests() - mocks.call.mockResolvedValue(historyResult) - mocks.subscribe.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) mocks.store?.setState({ agentStatusByPaneKey: {}, testRuntimeOwner: null, @@ -163,7 +154,7 @@ describe('StructuredAgentSessionStatusBridge', () => { afterEach(() => { cleanup() - resetStructuredAgentSessionReadOwnersForTests() + resetStructuredAgentSessionStatusFeedsForTests() }) it('reuses the structured-tab projection for an unchanged tab map', () => { @@ -194,65 +185,111 @@ describe('StructuredAgentSessionStatusBridge', () => { ]) }) - it('keeps restored inactive tabs transport-neutral', async () => { + it('projects the host status feed without opening a transcript reader', async () => { render() - await act(() => Promise.resolve()) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + expect(feed().target).toEqual({ kind: 'local' }) + expect(mocks.subscribeTranscript).not.toHaveBeenCalled() - expect(mocks.call).not.toHaveBeenCalled() - expect(mocks.subscribe).not.toHaveBeenCalled() + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'working', + prompt: 'hello', + agentType: 'codex', + sessionBoundary: false, + tabId: structuredTab.id, + worktreeId: 'wt-1', + terminalTitle: 'Codex Chat', + terminalResumeEligible: false, + providerSession + }) + ]) + }) + + // Hiddenness is the host's side of this: see structured-agent-session-subscribers.test.ts, + // which drives an unsubscribed journal through the feed. Here the transport is a mock, so + // only the summary-to-store mapping is under test. + it('maps each host status onto the sidebar agent state', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + expect(statuses()).toEqual([expect.objectContaining({ state: 'working' })]) + + act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) + expect(statuses()).toEqual([expect.objectContaining({ state: 'done', sessionBoundary: true })]) + + act(() => + feed().emit({ type: 'status', session: summary({ status: 'attention', updatedAt: 3 }) }) + ) + expect(statuses()).toEqual([expect.objectContaining({ state: 'blocked' })]) + }) + + it('shows no status before a persisted turn', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'snapshot', sessions: [summary({ status: null })] })) expect(mocks.setAgentStatus).not.toHaveBeenCalled() + + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 2 }) })) + expect(statuses()).toEqual([expect.objectContaining({ state: 'working' })]) }) - it('shares the visible pane subscriber with status projection', async () => { - render() - - await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) - expect(mocks.call).toHaveBeenCalledOnce() - expect(mocks.subscribe).toHaveBeenCalledOnce() - expect(mocks.setAgentStatus.mock.calls[0]?.[5]).toEqual({ - providerSession: historyResult.providerSession, - terminalResumeEligible: false - }) - }) - - it('keeps the status map reference stable for coalesced assistant deltas', async () => { - render() - await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) + it('keeps the status map reference stable for repeated equal summaries', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) const before = mocks.store?.getState().agentStatusByPaneKey - const onEvent = mocks.subscribe.mock.calls[0]?.[2] as (event: unknown) => void act(() => { - for (let sequence = 2; sequence <= 12; sequence += 1) { - onEvent({ - type: 'batch', - sessionId: 'session-1', - batch: { - cursor: { epoch: 'epoch-1', sequence }, - items: [ - { - itemId: 'assistant-1', - revision: sequence, - sequence, - observedAt: sequence, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `delta-${sequence}` }] - } - } - ], - removedItemIds: [], - submissions: [] - } - }) + for (let updatedAt = 2; updatedAt <= 12; updatedAt += 1) { + feed().emit({ type: 'status', session: summary({ updatedAt }) }) } }) - await act(async () => new Promise((resolve) => setTimeout(resolve, 60))) expect(mocks.setAgentStatus).toHaveBeenCalledOnce() expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) }) + it('drops the status and the feed when the last structured tab closes', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + expect(statuses()).toHaveLength(1) + + act(() => mocks.store?.setState({ unifiedTabsByWorktree: { 'wt-1': [] } })) + + expect(statuses()).toEqual([]) + await waitFor(() => expect(mocks.unsubscribe).toHaveBeenCalledOnce()) + }) + + it('reconnects after the host ends the stream', async () => { + vi.useFakeTimers() + try { + render() + await act(() => Promise.resolve()) + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + + act(() => feed().emit({ type: 'end' })) + await act(() => vi.advanceTimersByTimeAsync(300)) + + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + expect(mocks.subscribeStatus).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('keys the feed by the worktree runtime environment', async () => { + mocks.store?.setState({ testRuntimeOwner: 'env-1' }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + expect(feed().target).toEqual({ kind: 'environment', environmentId: 'env-1' }) + }) + it('does not project an unknown provider as Codex', async () => { mocks.store?.setState({ unifiedTabsByWorktree: { @@ -262,8 +299,7 @@ describe('StructuredAgentSessionStatusBridge', () => { render() await act(() => Promise.resolve()) - expect(mocks.call).not.toHaveBeenCalled() - expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.subscribeStatus).not.toHaveBeenCalled() expect(mocks.setAgentStatus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 8409858dd17..d4cb74ab93b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,19 +1,14 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' -import { - hasPersistedStructuredAgentSessionTurn, - projectStructuredAgentSessionStatus, - structuredAgentSessionPaneKey -} from '../../../../shared/structured-agent-session-projection' -import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import type { Tab } from '../../../../shared/tab-types' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { useStructuredAgentSessionReadObservation } from './use-structured-agent-session-read' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' type StructuredTab = Tab & { contentType: 'agent-session' } @@ -47,35 +42,40 @@ export function getStructuredAgentSessionTabs( return tabs } -function latestPrompt(state: StructuredAgentSessionState): string { - for (let index = state.items.length - 1; index >= 0; index -= 1) { - const body = state.items[index]?.body - if (body?.kind === 'message' && body.role === 'user') { - return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') - } - } - return '' +/** The host's projected status for one session, live while the caller is mounted. */ +function useStructuredAgentSessionStatusSummary( + sessionId: string, + target: RuntimeClientTarget +): AgentSessionStatusSummary | null { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId) ?? null, + () => null + ) } -function projectStatus( - tab: StructuredTab, - state: StructuredAgentSessionState, - providerSession: AgentProviderSessionMetadata | undefined -): void { +function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | null): void { const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) const store = useAppStore.getState() - if (!hasPersistedStructuredAgentSessionTurn(state.items)) { + // No persisted turn yet (or nothing known): the row shows no agent status at all. + if (!summary?.status) { if (store.agentStatusByPaneKey?.[paneKey]) { store.removeAgentStatus(paneKey) } return } - const projection = projectStructuredAgentSessionStatus(state.items) const desired = { - state: projection === 'working' ? 'working' : projection === 'attention' ? 'blocked' : 'done', - prompt: latestPrompt(state), + state: + summary.status === 'working' + ? 'working' + : summary.status === 'attention' + ? 'blocked' + : 'done', + prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, - sessionBoundary: projection === 'idle' + sessionBoundary: summary.status === 'idle' } as const const current = store.agentStatusByPaneKey?.[paneKey] if ( @@ -87,7 +87,11 @@ function projectStatus( current.tabId === tab.id && current.worktreeId === tab.worktreeId && current.terminalResumeEligible === false && - agentProviderSessionsEqual(tab.agentSessionAgent, current.providerSession, providerSession) + agentProviderSessionsEqual( + tab.agentSessionAgent, + current.providerSession, + summary.providerSession + ) ) { return } @@ -98,7 +102,7 @@ function projectStatus( undefined, { tabId: tab.id, worktreeId: tab.worktreeId }, { - ...(providerSession ? { providerSession } : {}), + ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), terminalResumeEligible: false } ) @@ -112,13 +116,10 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab }) () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), [environmentId] ) - const { providerSession, state } = useStructuredAgentSessionReadObservation({ - sessionId: tab.entityId, - target - }) + const summary = useStructuredAgentSessionStatusSummary(tab.entityId, target) useEffect(() => { - projectStatus(tab, state, providerSession) - }, [providerSession, state, tab]) + projectStatus(tab, summary) + }, [summary, tab]) useEffect( () => () => useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx index 2d320694c9c..9e1ce8067b7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx @@ -19,10 +19,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ subscribeStructuredAgentSession: mocks.subscribe })) -import { - useStructuredAgentSessionRead, - useStructuredAgentSessionReadObservation -} from './use-structured-agent-session-read' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' const LOCAL_TARGET = { kind: 'local' } as const @@ -357,32 +354,6 @@ describe('useStructuredAgentSessionRead history window', () => { second.unmount() }) - it('shares one subscriber when pane and projection observe the same visible session', async () => { - const unsubscribe = vi.fn() - mocks.call.mockResolvedValue({ ok: true, page: page('tail', [], false) }) - mocks.subscribe.mockResolvedValue({ unsubscribe }) - - const view = renderHook(() => { - const pane = useStructuredAgentSessionRead({ - sessionId: 'session-shared', - target: LOCAL_TARGET, - isVisible: true - }) - const projection = useStructuredAgentSessionReadObservation({ - sessionId: 'session-shared', - target: LOCAL_TARGET - }) - return { pane, projection } - }) - - await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledOnce()) - expect(mocks.call).toHaveBeenCalledOnce() - expect(view.result.current.pane.state).toBe(view.result.current.projection.state) - - view.unmount() - expect(unsubscribe).toHaveBeenCalledOnce() - }) - it('preserves cached state while switching away and refreshes once on re-entry', async () => { const unsubscribe = vi.fn() mocks.call.mockImplementation((_target, _method, params) => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts index 894730f5e65..834d11d3fa1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -20,13 +20,6 @@ function useReadOwnerSnapshot( return { owner, snapshot } } -export function useStructuredAgentSessionReadObservation(args: { - sessionId: string - target: RuntimeClientTarget -}): StructuredAgentSessionReadSnapshot { - return useReadOwnerSnapshot(args.sessionId, args.target).snapshot -} - export function useStructuredAgentSessionRead(args: { sessionId: string target: RuntimeClientTarget diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 2e611e4c726..9a42ccb6da6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -296,4 +296,40 @@ describe('useStructuredAgentSession options', () => { expect(result.current.error).toBeNull() }) + + it('includes one background task id in the cancel fingerprint and payload', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: true, + value: { turnId: 'background-tasks', cancelled: true } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.stopBackgroundTask('task-2')).resolves.toMatchObject({ + cancelled: true + }) + }) + + const mutation = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(mutation?.[2]).toMatchObject({ + envelope: { + sessionId: 'session-1', + expectedRuntimeFence: 3 + }, + turnId: 'background-tasks', + scope: 'background-tasks', + taskId: 'task-2' + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 3ae45bc3f43..8af9a36e0c4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -245,12 +245,14 @@ export function useStructuredAgentSession(args: { isWorking: turnId !== null, isMonitoringBackgroundTasks, backgroundTasks: state.backgroundTasks?.tasks ?? [], + supportsBackgroundTaskStop: state.backgroundTasks?.supportsTaskStop === true, turnId, cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), - stopBackgroundTasks: () => + stopBackgroundTask: (taskId?: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId: 'background-tasks', - scope: 'background-tasks' + scope: 'background-tasks', + ...(taskId ? { taskId } : {}) }), respond: (item: StructuredPromptItem, optionId: string) => mutate( diff --git a/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts index e93928bf4fd..688683bce13 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.test.ts @@ -37,6 +37,66 @@ describe('selectDeletionRoots', () => { const other = node('/repo/a.ts/impossible-child') expect(selectDeletionRoots([file, other])).toEqual([file, other]) }) + + it('reads directory membership once per selected node, including file-only selections', () => { + let directoryReads = 0 + const nodes = Array.from({ length: 1_000 }, (_, index) => ({ + ...node(`/repo/file-${index}.ts`), + get isDirectory() { + directoryReads += 1 + return false + } + })) + const selected = selectDeletionRoots(nodes) + expect(directoryReads).toBe(nodes.length) + expect(selected).not.toBe(nodes) + selected.forEach((entry, index) => expect(entry).toBe(nodes[index])) + }) + + it('preserves order, node identity and host ownership when children precede parents', () => { + const child = node('/repo/docs/guide.md') + const first = { ...node('/repo/first.ts'), operationOwner: { kind: 'local' as const } } + const parent = { + ...node('/repo/docs', true), + operationOwner: { kind: 'ssh' as const, connectionId: 'ssh-owner' } + } + const last = { + ...node('/repo/last.ts'), + operationOwner: { kind: 'unresolved' as const } + } + const nodes = [child, first, parent, last] + const selected = selectDeletionRoots(nodes) + expect(selected).toEqual([first, parent, last]) + expect(selected[0]).toBe(first) + expect(selected[1]).toBe(parent) + expect(selected[2]).toBe(last) + expect(nodes).toEqual([child, first, parent, last]) + }) + + it('keeps repeated references but excludes distinct directories with the same path', () => { + const dir = node('/repo/docs', true) + expect(selectDeletionRoots([dir, dir])).toEqual([dir, dir]) + expect(selectDeletionRoots([dir, { ...dir }])).toEqual([]) + const file = node('/repo/docs') + expect(selectDeletionRoots([file, dir])).toEqual([dir]) + }) + + it.each([ + ['/repo/docs/', '/repo/docs/guide.md', '/repo/docs-other/guide.md'], + ['C:\\Repo\\Docs\\', 'c:/repo/docs/guide.md', 'C:/Repo/Docs-other/guide.md'], + ['\\\\Server\\Share\\Docs', '//server/share/docs/guide.md', '//server/other/docs/guide.md'] + ])('retains path boundaries for %s', (parentPath, childPath, outsidePath) => { + const parent = node(parentPath, true) + const child = node(childPath) + const outside = node(outsidePath) + expect(selectDeletionRoots([outside, child, parent])).toEqual([outside, parent]) + }) + + it('keeps case-distinct POSIX paths', () => { + const parent = node('/repo/Docs', true) + const outside = node('/repo/docs/guide.md') + expect(selectDeletionRoots([outside, parent])).toEqual([outside, parent]) + }) }) describe('runBatchDeletion', () => { diff --git a/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.ts b/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.ts index 63a5d029739..5f052bec588 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-batch-deletion.ts @@ -5,11 +5,9 @@ import type { TreeNode } from './file-explorer-types' // already removes the child, and issuing both requests races on the // now-missing path and produces spurious errors. export function selectDeletionRoots(nodes: TreeNode[]): TreeNode[] { + const directories = nodes.filter((node) => node.isDirectory) return nodes.filter( - (n) => - !nodes.some( - (other) => other !== n && other.isDirectory && isPathEqualOrDescendant(n.path, other.path) - ) + (n) => !directories.some((other) => other !== n && isPathEqualOrDescendant(n.path, other.path)) ) } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts index 4e5b6e7b423..f21116c7822 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts @@ -9,8 +9,5 @@ function isDotfileSegment(segment: string): boolean { } export function isDotfileRelativePath(relativePath: string): boolean { - return relativePath - .split(/[\\/]+/) - .filter(Boolean) - .some(isDotfileSegment) + return relativePath.split(/[\\/]+/).some(isDotfileSegment) } diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/commit-notices.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/commit-notices.tsx index e46b1f6b3ea..666181a649a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/commit-notices.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/commit-notices.tsx @@ -113,20 +113,20 @@ export function CommitNotices({ role={createPrIntentNotice.tone === 'destructive' ? 'alert' : 'status'} aria-live="polite" className={cn( - 'mt-1 flex min-w-0 items-center gap-1.5 text-[11px]', + 'mt-1 flex min-w-0 flex-col items-start gap-1 text-[11px]', createPrIntentNotice.tone === 'destructive' ? 'text-destructive' : 'text-muted-foreground' )} > {/* Why: Create Review blockers carry recovery steps; truncating hides the action the user needs in a narrow sidebar. */} - + {createPrIntentNotice.message} {createPrIntentNotice.action === 'settings' && onOpenSourceControlAiSettings ? (
    + ) } diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index ca5a8c5bce1..3a00b8ee079 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { ActivityScopeFilterChips } from '@/components/activity/activity-scope-filter-controls' import { hasActivityThreadWorkspace } from '@/components/activity/activity-thread-actions' import { useActivityThreadActionBindings } from '@/components/activity/use-activity-thread-action-bindings' import { ActivityThreadListPane } from '@/components/activity/activity-thread-list-pane' @@ -164,7 +163,6 @@ export default function SidebarAgentsList({ showFilterControls={false} showOptionsMenu={false} showInlineActions={false} - scopeFilterRow={} scrollTopRef={scrollTopRef} /> {optionsTarget diff --git a/src/renderer/src/components/sidebar/header-drag-click-swallow.ts b/src/renderer/src/components/sidebar/header-drag-click-swallow.ts new file mode 100644 index 00000000000..1e875558b70 --- /dev/null +++ b/src/renderer/src/components/sidebar/header-drag-click-swallow.ts @@ -0,0 +1,20 @@ +/** + * Swallow the click that follows a completed header drag. + * + * Why: the pointerup that ends a promoted drag is followed by a click on the + * drag handle, which would also toggle the section the user just reordered. + * The listener removes itself on the first click; the returned timeout handle + * is the fallback for a drop that produces no click. + */ +export function swallowNextClickOnDragHandle(handleEl: HTMLElement): ReturnType { + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + return setTimeout(() => window.removeEventListener('click', swallow, true), 0) +} diff --git a/src/renderer/src/components/sidebar/header-drag-pointer-release.ts b/src/renderer/src/components/sidebar/header-drag-pointer-release.ts new file mode 100644 index 00000000000..4e8466ed3e7 --- /dev/null +++ b/src/renderer/src/components/sidebar/header-drag-pointer-release.ts @@ -0,0 +1,14 @@ +/** + * True when a pointermove arrives with no button held, meaning the pointerup + * that should have ended the armed drag never reached us. + * + * Why: the header drag hooks subscribe to window pointer events from an effect + * armed by pointerdown state, so a fast click's release can land before that + * effect runs (a heavy sidebar render sits between them). The session then + * survives the click and the next hover promotes a drag the user is not doing — + * for host sections that hides the header outright and force-collapses every + * host. A capture-phase listener swallowing pointerup has the same effect. + */ +export function hasPointerBeenReleased(event: PointerEvent): boolean { + return event.buttons === 0 +} diff --git a/src/renderer/src/components/sidebar/host-header-drag.test.tsx b/src/renderer/src/components/sidebar/host-header-drag.test.tsx new file mode 100644 index 00000000000..6c9e0f66e33 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-drag.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import React from 'react' +import { act, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useHostHeaderDrag } from './host-header-drag' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +function setup() { + const scrollContainer = document.createElement('div') + document.body.append(scrollContainer) + const controller: { current: ReturnType | null } = { current: null } + + function Harness(): React.JSX.Element { + const drag = useHostHeaderDrag({ + orderedHostIds: ['ssh:host-a', 'ssh:host-b'] as ExecutionHostId[], + onCommit: vi.fn(), + getScrollContainer: () => scrollContainer + }) + controller.current = drag + return ( +
    drag.onHandlePointerDown(event, 'ssh:host-a')} + /> + ) + } + + const view = render() + const header = view.container.querySelector('[data-host-header-drag-id]')! + header.setPointerCapture = vi.fn() + header.releasePointerCapture = vi.fn() + return { controller, header } +} + +function pointer(type: string, init: PointerEventInit): PointerEvent { + return new PointerEvent(type, { bubbles: true, pointerId: 1, ...init }) +} + +describe('useHostHeaderDrag', () => { + it('does not start a drag when the pointer is released before the window listeners attach', () => { + const { controller, header } = setup() + + // A click: pointerdown arms the session, pointerup lands before React has + // flushed the passive effect that subscribes to window pointer events. + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + window.dispatchEvent(pointer('pointerup', { clientX: 10, clientY: 10 })) + }) + + // Moving the mouse afterwards, with no button held, must not promote a drag. + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 400, buttons: 0 })) + }) + + expect(controller.current?.state.draggingHostId).toBeNull() + }) + + it('clears a session whose pointerup was missed so a later drag still works', () => { + const { controller, header } = setup() + + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + window.dispatchEvent(pointer('pointerup', { clientX: 10, clientY: 10 })) + }) + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 400, buttons: 0 })) + }) + + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + }) + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 40, clientY: 60, buttons: 1 })) + }) + + expect(controller.current?.state.draggingHostId).toBe('ssh:host-a') + }) + + it('still promotes a drag while the pointer stays down', () => { + const { controller, header } = setup() + + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + }) + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 40, clientY: 60, buttons: 1 })) + }) + + expect(controller.current?.state.draggingHostId).toBe('ssh:host-a') + }) +}) diff --git a/src/renderer/src/components/sidebar/host-header-drag.ts b/src/renderer/src/components/sidebar/host-header-drag.ts index 8b6ea676a46..cb1955dda02 100644 --- a/src/renderer/src/components/sidebar/host-header-drag.ts +++ b/src/renderer/src/components/sidebar/host-header-drag.ts @@ -16,6 +16,8 @@ import { readHostHeaderRects, type HostHeaderRect } from './host-header-drag-dom' +import { hasPointerBeenReleased } from './header-drag-pointer-release' +import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' export type HostDragState = { draggingHostId: ExecutionHostId | null @@ -157,17 +159,7 @@ export function useHostHeaderDrag({ session.preview?.remove() setSidebarPointerDragDocumentStyles(false) if (session.promoted) { - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - setTimeout(() => window.removeEventListener('click', swallow, true), 0) + swallowNextClickOnDragHandle(session.handleEl) } const finalIndex = commit && session.promoted @@ -206,6 +198,10 @@ export function useHostHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } + if (hasPointerBeenReleased(e)) { + endDrag(false) + return + } if (!session.promoted) { const dx = e.clientX - session.startX const dy = e.clientY - session.startY diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts index e2a879aebdc..853adfc751f 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -15,6 +15,8 @@ import { } from './project-group-header-drag-contract' import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' +import { hasPointerBeenReleased } from './header-drag-pointer-release' +import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' // Why pointer events instead of HTML5 DnD: Project Group rows are virtualized // and may unmount while scrolling; cached row-model indices keep drops stable. @@ -114,20 +116,7 @@ export function useProjectGroupHeaderDrag({ // capture may already be released (pointercancel, element unmounted) } if (session.promoted) { - const handleEl = session.handleEl - const swallow = (event: MouseEvent): void => { - const target = event.target as Node | null - if (target && handleEl.contains(target)) { - event.stopPropagation() - event.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = setTimeout(() => { - window.removeEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = null - }, 0) + clickSwallowTimeoutRef.current = swallowNextClickOnDragHandle(session.handleEl) } const sidebarDropIndex = commit && session.promoted && latestDropIndexRef.current !== null @@ -199,6 +188,10 @@ export function useProjectGroupHeaderDrag({ if (!session || event.pointerId !== session.pointerId) { return } + if (hasPointerBeenReleased(event)) { + endDrag(false) + return + } session.latestPointerY = event.clientY if (!session.promoted) { const dx = event.clientX - session.startX diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 4d13ddfa0af..68ab299d616 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -15,6 +15,8 @@ import { } from './project-header-drag-contract' import { createProjectHeaderDragSession } from './project-header-drag-start' import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' +import { hasPointerBeenReleased } from './header-drag-pointer-release' +import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' // Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by // react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire @@ -124,20 +126,7 @@ export function useRepoHeaderDrag({ // capture may already be released (pointercancel, element unmounted) } if (session.promoted) { - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = setTimeout(() => { - window.removeEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = null - }, 0) + clickSwallowTimeoutRef.current = swallowNextClickOnDragHandle(session.handleEl) } const sidebarDropIndex = commit && session.promoted && latestDropIndexRef.current !== null @@ -212,6 +201,10 @@ export function useRepoHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } + if (hasPointerBeenReleased(e)) { + endDrag(false) + return + } session.latestPointerY = e.clientY if (!session.promoted) { const dx = e.clientX - session.startX diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index b72f27287f9..3b716c3bd51 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -191,14 +191,12 @@ export default function BrowserTab({ muted-foreground made the icon read as "disabled" in practice. */} {isPinned && } {tabLabel} - {tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && ( - - )} {!isPinned && (