Merge remote-tracking branch 'origin/main' into fix14974d-work

# Conflicts:
#	src/main/providers/ssh-pty-provider.test.ts
This commit is contained in:
Brennan Benson
2026-08-18 16:40:38 -07:00
1514 changed files with 97551 additions and 24642 deletions
+10
View File
@@ -12,3 +12,13 @@
/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.
/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.
/config/patches/@xterm__xterm@*.patch -diff
/config/patches/xterm-src/*.patch text eol=lf
# Generated wrapper fixtures: collapse them in the PR diff so they stop drowning
# the reviewable change, and pin LF because they are compared byte-for-byte.
# Not -diff: the shell diff is the review surface when a wrapper does change.
/src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf
@@ -21,6 +21,7 @@ runs:
run_install: false
- name: Setup Node.js
id: default-node
if: inputs.node-version == ''
uses: actions/setup-node@v6
with:
@@ -28,6 +29,7 @@ runs:
cache: pnpm
- name: Setup requested Node.js
id: requested-node
if: inputs.node-version != ''
uses: actions/setup-node@v6
with:
@@ -63,15 +65,27 @@ runs:
rm -rf node_modules
fi
# Why --frozen-lockfile: re-resolving pulls ~62 MB of registry packuments per job
# (measured) to recompute what the lockfile already pins, and the `git diff` guard
# below fails the run whenever that recomputation would have changed anything. The
# guard stays so a stale lockfile still fails by name rather than by resolver error.
- name: Install dependencies
shell: bash
run: |
pnpm install \
--no-frozen-lockfile \
--prefer-frozen-lockfile=false \
--ignore-scripts
pnpm install --frozen-lockfile --ignore-scripts
git diff --exit-code package.json pnpm-lock.yaml
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
# Node version, and the patch whose contents the build has to match.
- name: Restore compiled native modules
if: inputs.native-runtime != 'none'
uses: actions/cache@v5
with:
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
shell: bash
+19 -3
View File
@@ -68,7 +68,15 @@ function writeGithubOutput(totals) {
}
}
async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetchImpl = fetch }) {
export async function updatePullRequest({
owner,
repo,
pullNumber,
token,
totals,
fetchImpl = fetch,
sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
}) {
const headers = githubHeaders(token)
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
const response = await fetchImpl(url, { headers })
@@ -87,14 +95,22 @@ async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetch
return 0
}
const update = await fetchImpl(url, {
const updateRequest = {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({ body: nextBody })
})
}
let update
for (let attempt = 0; attempt < 3; attempt += 1) {
update = await fetchImpl(url, updateRequest)
if (update.ok || ![500, 502, 503, 504].includes(update.status) || attempt === 2) {
break
}
await sleepImpl(1000 * 2 ** attempt)
}
if (update.status === 403) {
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
return 0
+4 -1
View File
@@ -3,7 +3,10 @@ export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
export const LOC_HANDS_OFF_COMMENT =
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
// Why the __-wrapped names: they are unambiguous test-data markers, so a dir
// cannot be one by accident. Bare `fixtures/` is deliberately absent — it reads
// as a plausible prod module name, and this classifier bills every PR.
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|__fixtures__|__snapshots__|e2e|tests)(?:\/|$)/i
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
export function isTestPath(path) {
+54 -28
View File
@@ -46,16 +46,19 @@ jobs:
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
@@ -70,18 +73,23 @@ jobs:
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
- name: Build Electron app for E2E
- name: Build E2E outputs
env:
VITE_EXPOSE_STORE: 'true'
run: |
npx electron-vite build --mode e2e
pnpm run build:web-from-renderer
pnpm run build:relay
- name: Upload E2E build output
uses: actions/upload-artifact@v7
with:
name: e2e-build-out
path: out/
# Why: build-relay.mjs writes each relay's marker as `out/relay/<platform>/.version`,
# and upload-artifact drops dotfiles by default — consumers then fail SSH specs with
# "local relay build is missing its version marker".
include-hidden-files: true
retention-days: 1
if-no-files-found: error
@@ -126,7 +134,7 @@ jobs:
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 zsh
# Why: Electron on Linux needs an X display even when the app
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
@@ -134,16 +142,19 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
@@ -174,7 +185,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 pnpm run test:e2e --shard=${{ matrix.shard }}
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 }}
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
@@ -193,7 +204,9 @@ jobs:
needs: build
if: inputs.test_files != ''
runs-on: ubuntu-latest
timeout-minutes: 30
# Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
# pay a container image build plus ~22 serial SSH tests on top of the changed specs.
timeout-minutes: 45
steps:
- name: Checkout
@@ -202,17 +215,23 @@ 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 python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
# Why ripgrep: Quick Open's bounded host-side search requires rg instead of an
# 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
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
@@ -240,8 +259,12 @@ jobs:
echo "Changed startup-readiness specs are owned by the dedicated live lane."
exit 0
fi
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay")
# Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so
# naming only one trigger silently skipped every other Docker-SSH spec in this lane.
# The first clause stays because that spec needs Docker without referencing the variable.
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \
|| grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
fi
E2E_PROJECT_ARGS=()
@@ -279,17 +302,20 @@ 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 openssh-client python3 xvfb zsh
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
cache: pnpm
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
+12
View File
@@ -32,6 +32,14 @@ jobs:
runs-on: macos-26
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
timeout-minutes: 90
env:
# Why: this job and ios-distribute resolve gems ~25 minutes apart, so both
# must install the committed Gemfile.lock exactly. Frozen turns a lockfile
# drift into a setup failure instead of two different fastlane versions in
# one release.
BUNDLE_FROZEN: 'true'
outputs:
release_version: ${{ steps.release_metadata.outputs.version }}
build_number: ${{ steps.release_metadata.outputs.build_number }}
@@ -149,6 +157,10 @@ jobs:
# margin without allowing App Store Connect polling to hang for hours.
timeout-minutes: 30
env:
# Same fastlane as ios-build, or fail before touching App Store Connect.
BUNDLE_FROZEN: 'true'
defaults:
run:
working-directory: mobile
+30 -1
View File
@@ -12,11 +12,21 @@ on:
# Why: the mobile terminal link parsers are conformance-tested against
# these shared fixtures; desktop-side fixture edits must re-run this suite.
- 'src/shared/terminal-file-link-conformance.ts'
# 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/workflows/mobile-ios-release.yml'
jobs:
verify:
runs-on: ubuntu-latest
env:
# Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts
# from the Gemfile, which is how the release jobs could land on different
# fastlane versions in the first place. Fail here instead.
BUNDLE_FROZEN: 'true'
defaults:
run:
working-directory: mobile
@@ -30,10 +40,15 @@ jobs:
with:
node-version-file: package.json
- name: Setup Ruby
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
# proves the pinned fastlane the release workflow depends on still
# resolves — before a release run finds out.
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: mobile
- name: Setup pnpm
uses: pnpm/action-setup@v6
@@ -64,6 +79,20 @@ jobs:
- name: Test iOS release version resolution
run: ruby fastlane/ios_release_version_test.rb
- name: Test TestFlight lane arguments
run: ruby fastlane/fastfile_testflight_arguments_test.rb
# Why: nothing else in CI loads the Fastfile, so a syntax error, a broken
# require, or an undefined constant only surfaces mid-release — the
# ios-distribute job failed every run for six days that way. `lanes` just
# loads and lists, so it needs no App Store Connect credentials and makes
# no network calls to Apple.
- name: Smoke-check the Fastfile
env:
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_OPT_OUT_USAGE: '1'
run: bundle exec fastlane lanes
- name: Lint
run: pnpm lint
+126 -13
View File
@@ -24,7 +24,11 @@ jobs:
- 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
- uses: ./.github/actions/install-node-dependencies
@@ -122,7 +126,11 @@ jobs:
- 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: Reject new root-level files and folders
@@ -142,7 +150,19 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
- run: pnpm typecheck
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
# the next run skip unchanged files. CI threw it away each time. The key is per-SHA
# so each run saves its own; restore-keys inherit the newest prior graph to diff against.
- name: Cache TypeScript incremental state
uses: actions/cache@v5
with:
path: config/*.tsbuildinfo
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
restore-keys: |
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
tsbuildinfo-${{ runner.os }}-
- run: pnpm run typecheck
git_compatibility:
name: Git compatibility
@@ -156,19 +176,34 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
# Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
# same binary on every PR for minutes of runner time. The key carries the version
# because that is the only input; the sha256 assertion below still guards the
# tarball on the miss path that actually builds.
- name: Cache baseline Git build
uses: actions/cache@v5
with:
path: ~/.cache/orca-git-compat/git-2.25.5
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
- name: Verify Git binary compatibility matrix
run: |
pids=()
(
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j"$(nproc)" \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
source="$HOME/.cache/orca-git-compat/git-2.25.5"
if [ ! -x "$source/git" ]; then
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j"$(nproc)" \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
# Why: the linked binaries are what the next run needs; the objects that
# produced them are most of the tree and would bloat the cache entry.
find "$source" -name '*.o' -delete
fi
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
@@ -194,6 +229,34 @@ jobs:
done
exit "$status"
xterm_patch_sync:
name: xterm patch sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v5
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
- name: Verify xterm patches match the pinned upstream build
env:
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
@@ -224,14 +287,17 @@ jobs:
# non-zero and would red this required check over something unrelated to the
# PR. Every fish outcome is judged by the version gate below instead, so only
# the zsh install (which has no such gate) stays fatal here.
sudo apt-get update || true
# Why retry only here: adding the PPA is the network-flaky step, and the
# version gate below is fatal, so a transient Launchpad blip would
# otherwise red a required check on PRs unrelated to shells.
# Why -n: add-apt-repository refreshes every configured repo on its own. With
# an update on each side of it this step refreshed them three times over, and
# the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the
# only thing the repo list gains here, and the single update below fetches it.
for attempt in 1 2 3; do
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true
sleep 5
done
sudo apt-get update || true
@@ -270,8 +336,10 @@ jobs:
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/shell-startup-feature-channel.test.ts \
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
@@ -312,8 +380,10 @@ jobs:
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
@@ -331,7 +401,11 @@ jobs:
- 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
- uses: ./.github/actions/install-node-dependencies
@@ -465,14 +539,19 @@ jobs:
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
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/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/orca-profiles/profile-index-store.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
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
- name: Build package inputs
run: pnpm run build:release
run: pnpm run build:release:parallel
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -501,7 +580,11 @@ jobs:
- 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
@@ -515,6 +598,33 @@ jobs:
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
fi
# Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
# pane-restore regressions shipped from SSH source edits that touched no test.
# Unit tests are excluded because they prove the same source without Docker.
# ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
# ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
'tests/e2e/pty-input-write-queue-ssh.spec.ts' \
'tests/e2e/ssh-cold-activation-restore.spec.ts' \
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \
'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
fi
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
# config/patches is listed because the terminal's input handling lives in the xterm patch.
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
@@ -543,6 +653,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- xterm_patch_sync
- shell_contracts
- test
- managed_hook_node18
@@ -565,6 +676,7 @@ jobs:
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
@@ -576,6 +688,7 @@ jobs:
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
+9
View File
@@ -105,8 +105,12 @@ docs/**
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/ssh-host-key-verification.md
!docs/reference/ssh-reconnect-source-recovery.md
!docs/reference/windows-setup-shell.md
!docs/reference/worktree-scan-fingerprint.md
!docs/reference/wsl-command-execution.md
!docs/reference/xterm-patch-regeneration.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -148,3 +152,8 @@ tests/tools/benchmarks/results/terminal-pipeline-*.json
# Old release trees the cross-version wire harness extracts on demand
tests/e2e/.cross-version-checkouts/
# Bundler's install path for the mobile release toolchain (mobile/Gemfile.lock
# IS committed). Also keeps oxfmt/oxlint, which honor this file, from walking
# vendored gems.
/mobile/vendor/
+1
View File
@@ -34,6 +34,7 @@ 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).
- **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.
## SSH Use Case
+1 -1
View File
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
+3
View File
@@ -129,6 +129,9 @@ module.exports = {
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
// Why: out/electron-dev caches `pnpm dev`'s per-branch Electron.app copies (~270MB each).
// CI never creates it, but packaging on a machine that has run dev would pack them all.
'!out/electron-dev{,/**/*}',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
-2
View File
@@ -29,7 +29,6 @@ inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.ts
inline src/main/git/worktree.ts
inline src/main/github/client.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
@@ -98,7 +97,6 @@ inline src/renderer/src/components/UpdateCard.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
"upstream": {
"repository": "https://github.com/xtermjs/xterm.js.git",
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
},
"sourcemaps": {
"policy": "include",
"why": "terminal-ime-xterm-transaction-events.test.ts reads lib/*.map and asserts the mapped Version.ts matches the runtime version, so the maps have to move with the bundles rather than be dropped. Patching them costs ~5.8MB of the emitted patch; the alternative, deleting them, is only available again once nothing reads them."
},
"toolchain": {
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
"esbuild": "0.28.1",
"webpack": "5.107.0",
"terser": "5.47.1",
"@typescript/native-preview": "7.0.0-dev.20260521.1"
},
"packages": [
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.287",
"packageDir": ".",
"versionStampFile": "src/common/Version.ts",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"generatedPaths": ["lib/"],
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
}
],
"forbiddenBuildScripts": {
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
}
}
+70 -24
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-14",
"updatedAt": "2026-08-18",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -943,18 +943,27 @@
"providers": ["lan", "tailscale", "cloud-relay"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["lan", "tailscale", "cloud-relay"],
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca-cloud/pull/96"],
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, and remains pending through a genuine failed-dial cooldown. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
"oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.",
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, continuous-outage escalation, persistent pairing-rejection escalation, direct-winner cancellation, credential-gated Relay presentation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca-cloud/pull/96",
"https://linear.app/stably/issue/STA-4587",
"https://linear.app/stably/issue/STA-4681"
],
"invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Relay recovery presentation starts only for a failed active Relay or a dial backed by an eligible credential, remains pending through a genuine failed-dial cooldown, and exposes the supervisor's consecutive failed-dial count so a continuous outage escalates to unreachable. A relay credential the desktop keeps refusing escalates to re-pair once its transient-rejection budget is spent, and only an authenticated session clears that verdict. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.",
"oracle": "Inject deterministic relay close codes, missing and expired credentials, delayed credential reads, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, keep Relay presentation absent when no socket can open, clear it across lifecycle races, retain it through real dial cooldowns, establish Relay through the supervisor and require its active close plus 11 failed replacements to publish reconnect attempt 12 and a Relay-unreachable verdict, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins. Drive a supervisor-established Relay through four simulated hours of E2EE rejections and require the re-pair verdict, require it to survive a background/foreground cycle, require two rejections followed by acceptance to never latch it, and require a plain transport outage to still read Relay-unreachable.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.test.ts mobile/src/transport/connection-health.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot"
],
"testFiles": [
"mobile/src/transport/mobile-direct-endpoint-probe.test.ts",
"mobile/src/transport/mobile-relay-reconnect-controller.test.ts",
"mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"mobile/src/transport/mobile-relay-outage-escalation.test.ts",
"mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts",
"mobile/src/transport/stable-logical-rpc-client.test.ts",
"mobile/src/transport/mobile-relay-runtime-failover.test.ts",
"mobile/src/transport/connection-health.test.ts",
"src/shared/mobile-relay-close-codes.test.ts"
],
"assertionRefs": [
@@ -981,6 +990,43 @@
"a genuine failed Relay dial remains pending through its retry cooldown"
]
},
{
"file": "mobile/src/transport/mobile-relay-outage-escalation.test.ts",
"assertions": [
"a supervisor-established Relay close plus 11 failed replacements publish reconnect attempt 12",
"the existing connection classifier escalates the continuous outage to Can't connect via Relay"
]
},
{
"file": "mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts",
"assertions": [
"a persistent E2EE rejection escalates to Pairing invalid — re-pair with your desktop",
"the re-pair verdict survives a background/foreground cycle",
"rejections inside the transient budget followed by acceptance never latch it",
"a plain transport outage still reads Can't connect via Relay"
]
},
{
"file": "mobile/src/transport/connection-health.test.ts",
"assertions": [
"a rejected pairing outranks a pending Relay recovery",
"a rejected pairing never overrides a live connection"
]
},
{
"file": "mobile/src/transport/stable-logical-rpc-client.test.ts",
"assertions": [
"supervisor Relay attempts overlay but never replace a larger active-session retry count",
"count-only changes notify connection-path subscribers and clearing recovery restores the physical count"
]
},
{
"file": "mobile/src/transport/mobile-relay-runtime-failover.test.ts",
"assertions": [
"a fresh credential starts Relay presentation with the controller's current count",
"missing or expired credentials never open a Relay socket"
]
},
{
"file": "src/shared/mobile-relay-close-codes.test.ts",
"assertions": ["HOST_OFFLINE maps to self-healing full-jitter recovery"]
@@ -988,13 +1034,13 @@
],
"evidenceRuns": [
{
"date": "2026-08-16",
"date": "2026-08-18",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/mobile-relay-pairing-rejection-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.test.ts mobile/src/transport/connection-health.test.ts",
"result": "passed",
"durationSeconds": 1.23,
"summary": "Three focused mobile transport files passed with 54 assertions."
"durationSeconds": 1.25,
"summary": "Eight focused mobile transport files passed with 109 assertions, including production-lifecycle outage escalation, persistent pairing-rejection escalation, and logical count publication."
},
{
"date": "2026-08-16",
@@ -1016,11 +1062,11 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The prior external-signal HOST_OFFLINE policy fails the retry oracle, and the prior serial direct probe fails the first-authenticated-endpoint timing oracle. Both pass with the candidate behavior."
"evidence": "Banking the pairing rejection after registerFailure's gated early return — the pre-fix placement — leaves the four-hour E2EE oracle on 'Connecting via Relay…' and reds 7 assertions while the 17 others stay green; moving it ahead of the gate turns them green. The pre-fix production-lifecycle oracle establishes Relay through the supervisor, then completes one active close plus 11 replacement failures while the public retry count remains 0; the candidate publishes 12 and reaches the existing Relay-unreachable verdict. The prior external-signal HOST_OFFLINE policy and serial direct probe also fail their respective retry and first-authenticated-endpoint timing oracles."
},
"performanceBudget": {
"required": true,
"evidence": "All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms in the deterministic test, and every losing client is closed. A physical-device radio and battery budget is still required before promotion."
"evidence": "The production diff only projects existing supervisor state — the failure count and a consecutive-rejection latch — through local setters and subscribers; it adds no call to a Relay dial, timer, socket, probe, or wire API, and the four screens read both recovery inputs from one host subscription instead of two. The deterministic outage asserts exactly one successful establishment plus 11 failed replacements at the escalation boundary. All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms, and every losing client is closed."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
@@ -2640,19 +2686,19 @@
"invariant": "A current remote browser stream that closes must retry transient failures on one counted, capped backoff chain. Cancellation, pane or runtime supersession, missing pages, and permanent failures stop that chain without stale state mutation. A stream that stays healthy refills the budget; a silent or flapping stream cannot retry forever. Exhaustion and every non-retrying failure leave an actionable Reconnect control, and manual reconnect creates a fresh stream without resurrecting stale subscriptions.",
"oracle": "With one fake clock and exact pane, operation, stream, page, and runtime identities, drop a confirmed stream and require the counted delay sequence, one active chain, stale-token rejection, exact cancellation, full cleanup, and either a new live subscription or one stopped state with Reconnect after exhaustion. Inject transport error without close, close racing rejection, never-ready subscriptions, ready-then-end flapping, viewport supersession, missing pages, and permanent failures. In a separate paired client against an isolated headless Orca host, paint one remote frame, disconnect the owning runtime, require the bounded-retry notice and Reconnect control, reconnect, click once, and require a different frame object URL from the recovered stream.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts",
"pnpm exec playwright test tests/e2e/paired-remote-browser-stream-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts",
"src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts",
"src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts",
"src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts",
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts",
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts",
"src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts",
"tests/e2e/paired-remote-browser-stream-reconnect.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts",
"file": "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts",
"assertions": [
"transient failures retry until recovery on counted delays",
"budget exhaustion stops exactly once",
@@ -2661,7 +2707,7 @@
]
},
{
"file": "src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts",
"file": "src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts",
"assertions": [
"dropped, silent, and flapping streams converge on a bounded retry or actionable stop",
"stale, superseded, missing-page, and permanent-failure work cannot mutate the current pane",
@@ -2683,7 +2729,7 @@
"date": "2026-08-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/remote-browser-stream-status.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-errors.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-scheduler.test.ts src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-status.test.ts",
"result": "passed",
"durationSeconds": 0.36,
"summary": "Four focused files passed 61 deterministic error, status, scheduler, lifecycle, in-flight ownership, stale-rejection, liveness, and cleanup tests."
@@ -9136,7 +9182,7 @@
"oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx"
],
"testFiles": [
"src/main/runtime/quarter-circle-title-send-authorization.test.ts",
@@ -9149,7 +9195,7 @@
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
"src/main/daemon/pty-subprocess.test.ts",
"src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts",
"src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
"src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx"
],
"assertionRefs": [
{
@@ -9219,7 +9265,7 @@
"assertions": ["selected active-agent note sends retain guarded paste and submit routing"]
},
{
"file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
"file": "src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx",
"assertions": [
"browser annotation send content routes through review-notes send so existing agent sessions remain selectable",
"both browser annotation send surfaces wire the shared menu content"
@@ -9240,7 +9286,7 @@
"date": "2026-07-11",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty-runtime-kill-and-exit.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider-foreground-process.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send-explicit-target.test.ts src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.test.tsx",
"result": "passed",
"durationSeconds": 9.58,
"summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing."
@@ -47,6 +47,18 @@ describe('computer-use skill guidance', () => {
expect(skill).not.toContain('`result.elements`')
})
it('explains how JSON and pretty output handle screenshots', () => {
expect(bundledGuide).toBeDefined()
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
expect(skill).toContain('request screenshots by default unless `--no-screenshot`')
expect(skill).toContain('A successful `--json` capture')
expect(skill).toContain('`result.screenshot.path`')
expect(skill).toContain('inline base64 `result.screenshot.data`')
expect(skill).toContain('Pretty output does not save')
}
})
it('requires atomic modifier-click actions in the source and bundled guide', () => {
expect(bundledGuide).toBeDefined()
@@ -0,0 +1,49 @@
// Why this module exists: `out/electron-dev` accumulates one ~270MB copy of Electron.app per
// (branch title x Electron version x bundle layout). The runner only ever clears the directory it is
// about to rebuild, so siblings from renamed branches and past upgrades are never reclaimed --
// measured at 143 directories / 38GB across one developer's worktrees.
// Measured from BUILD START, not from last activity: a directory's mtime only changes when a
// top-level entry is created, so it freezes once `<app>.app` appears and the 276MB copy, helper
// compiles and signing that follow never refresh it. So this is a hard cliff -- a build still
// running after this long becomes eligible for deletion by a concurrent instance. Full builds
// measured at 130-200s, so the margin is 5-10x.
export const IN_PROGRESS_WINDOW_MS = 15 * 60 * 1000
/**
* Which cached bundle directories are safe to reclaim.
*
* Three things are protected, and every one of them is a directory some other process still needs:
*
* - the bundle this run is about to use;
* - a bundle a live dev instance is running from. Deleting it can crash that instance mid-session,
* and developers routinely run several at once;
* - a bundle still being copied. Between `mkdirSync` and the marker write it exists, has no marker,
* and cannot appear in the process table because its instance has not launched yet -- so a
* concurrently starting instance would otherwise delete it mid-copy.
*
* `processTable` is raw `ps` output, searched for each candidate rather than parsed into paths.
* Parsing was tried twice and failed twice in the same dangerous direction -- a regex that missed a
* live process yielded "nothing is running" and deleted its bundle. Searching for a known absolute
* directory is immune to spaces and shell-significant characters in the path, and the trailing
* slash keeps `<dir>2` from being mistaken for `<dir>`.
*/
export function isDevBundleInUse(dir, processTable) {
// Both spellings: macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the
// process was launched with. A mismatch would read as "not running" and delete a live bundle.
// Checking both can only ever protect more, which is the safe direction.
const alternate = dir.startsWith('/private/') ? dir.slice('/private'.length) : `/private${dir}`
return processTable.includes(`${dir}/`) || processTable.includes(`${alternate}/`)
}
export function selectStaleDevBundleDirs({ bundles, currentDir, processTable, nowMs }) {
return bundles
.filter(({ dir, hasMarker, mtimeMs }) => {
if (dir === currentDir || isDevBundleInUse(dir, processTable)) {
return false
}
const buildInFlight = !hasMarker && nowMs - mtimeMs < IN_PROGRESS_WINDOW_MS
return !buildInFlight
})
.map(({ dir }) => dir)
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import {
IN_PROGRESS_WINDOW_MS,
isDevBundleInUse,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
const NOW = 1_800_000_000_000
const ROOT = '/repo/out/electron-dev'
const A = `${ROOT}/aaaaaaaaaaaa`
const B = `${ROOT}/bbbbbbbbbbbb`
const C = `${ROOT}/cccccccccccc`
/** A finished bundle: marker present, copied long ago. */
function settled(dir: string) {
return { dir, hasMarker: true, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS * 2 }
}
/** A `ps -Awwo command=` line for a dev instance running out of `dir`. */
function psLine(dir: string, appName = 'Orca: some-branch') {
return `${dir}/${appName}.app/Contents/MacOS/Electron --remote-debugging-port=9333`
}
describe('dev-electron-bundle-cache', () => {
it('reclaims siblings while keeping the current bundle', () => {
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(B), settled(C)],
currentDir: B,
processTable: '',
nowMs: NOW
})
).toEqual([A, C])
})
it('never reclaims a bundle a live process is running from', () => {
// Deleting a bundle out from under a live Electron process can crash it mid-session, and
// developers routinely run several dev instances at once.
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(B), settled(C)],
currentDir: B,
processTable: psLine(A),
nowMs: NOW
})
).toEqual([C])
})
it('protects a live bundle whose path contains spaces', () => {
// Regression: an extraction regex using \S* could not cross a space, so any developer with a
// space in their checkout path got "nothing is live" and had the running bundle deleted.
const spaced = '/Users/me/My Projects/orca/out/electron-dev/aaaaaaaaaaaa'
const other = '/Users/me/My Projects/orca/out/electron-dev/bbbbbbbbbbbb'
expect(
selectStaleDevBundleDirs({
bundles: [settled(spaced), settled(other)],
currentDir: C,
processTable: psLine(spaced, 'Orca: my branch'),
nowMs: NOW
})
).toEqual([other])
})
it('does not treat a sibling as live just because it shares a prefix', () => {
// Boundary: `<dir>2` being live must not protect `<dir>`. Without the trailing slash in the
// needle, `A` would be found inside `A2`'s path and wrongly spared.
const a2 = `${A}2`
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(a2)],
currentDir: C,
processTable: psLine(a2),
nowMs: NOW
})
).toEqual([A])
})
it('never deletes a bundle that is still being copied', () => {
// Between mkdirSync and the marker write there is a ~270MB copy taking tens of seconds, during
// which the directory exists, has no marker, and cannot appear in `ps` because its instance has
// not launched yet. A concurrently starting instance would otherwise delete it mid-copy.
const inFlight = { dir: A, hasMarker: false, mtimeMs: NOW - 30_000 }
expect(
selectStaleDevBundleDirs({
bundles: [inFlight, settled(B)],
currentDir: C,
processTable: '',
nowMs: NOW
})
).toEqual([B])
})
it('reclaims a marker-less bundle once it is too old to be in flight', () => {
// A build that crashed partway leaves the same signature; only age separates it from the case
// above, otherwise abandoned debris would be protected forever.
const abandoned = { dir: A, hasMarker: false, mtimeMs: NOW - IN_PROGRESS_WINDOW_MS - 1 }
expect(
selectStaleDevBundleDirs({
bundles: [abandoned],
currentDir: C,
processTable: '',
nowMs: NOW
})
).toEqual([A])
})
it('matches a live bundle across the /tmp and /private/tmp spellings', () => {
// macOS realpaths /tmp to /private/tmp, and `ps` preserves whatever spelling the process was
// launched with. A mismatch would read as "not running" and delete a live bundle.
const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
const plain = '/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
expect(isDevBundleInUse(priv, psLine(plain))).toBe(true)
expect(isDevBundleInUse(plain, psLine(priv))).toBe(true)
expect(isDevBundleInUse(priv, psLine('/private/tmp/wt/out/electron-dev/bbbbbbbbbbbb'))).toBe(
false
)
})
it('still honours the path boundary across spellings', () => {
const priv = '/private/tmp/wt/out/electron-dev/aaaaaaaaaaaa'
expect(isDevBundleInUse(priv, psLine('/tmp/wt/out/electron-dev/aaaaaaaaaaaa2'))).toBe(false)
})
it('reclaims nothing when every directory is current or live', () => {
expect(
selectStaleDevBundleDirs({
bundles: [settled(A), settled(B)],
currentDir: A,
processTable: psLine(B),
nowMs: NOW
})
).toEqual([])
expect(
selectStaleDevBundleDirs({ bundles: [], currentDir: A, processTable: '', nowMs: NOW })
).toEqual([])
})
})
@@ -0,0 +1,29 @@
// Why this module exists: the dev runner re-signs its copied Electron.app ad-hoc, so the bundle's
// designated requirement is a bare cdhash derived from its contents. macOS Keychain ACLs key off
// that requirement. Patching any branch-varying value into Info.plist therefore gave every branch a
// different code identity, and safeStorage re-prompted for a password on each one.
//
// The invariant: every value patched here must be CONSTANT across branches and worktrees, so all dev
// bundles share one cdhash and one Keychain ACL entry. Patching a key is fine; varying it is not.
// Per-branch Dock names come from the .app directory name, which is not part of the signature.
export const DEV_BUNDLE_ID = 'com.stablyai.orca.dev'
export const DEV_HELPER_BUNDLE_ID = `${DEV_BUNDLE_ID}.helper`
// Why a constant display name rather than none: leaving the stock value makes every dev
// notification and System Settings > Notifications row read "Electron", indistinguishable from any
// other Electron app. A fixed name keeps that legible without reintroducing per-branch drift.
export const DEV_BUNDLE_DISPLAY_NAME = 'Orca Dev'
/** Info.plist patches for the app bundle. Values must not vary per branch — see above. */
export function getDevBundlePlistPatches() {
return [
{ key: 'CFBundleIdentifier', value: DEV_BUNDLE_ID },
{ key: 'CFBundleName', value: DEV_BUNDLE_DISPLAY_NAME },
{ key: 'CFBundleDisplayName', value: DEV_BUNDLE_DISPLAY_NAME }
]
}
/** Info.plist patches for the embedded Electron Helper bundle. */
export function getDevHelperPlistPatches() {
return [{ key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID }]
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { getDevInstanceIdentity } from '../../src/main/startup/dev-instance-identity'
import {
DEV_BUNDLE_DISPLAY_NAME,
DEV_BUNDLE_ID,
DEV_HELPER_BUNDLE_ID,
getDevBundlePlistPatches,
getDevHelperPlistPatches
} from './dev-electron-bundle-identity.mjs'
const BRANCH_ENV_KEYS = [
'ORCA_DEV_DOCK_TITLE',
'ORCA_DEV_BRANCH',
'ORCA_DEV_INSTANCE_LABEL',
'ORCA_DEV_WORKTREE_NAME'
] as const
/** Collect the patch set as it would be computed on a given branch. */
function patchesUnder(dockTitle: string, branch: string) {
// Restores individual keys rather than reassigning process.env: that swaps Node's native env
// object for a plain one, which stops coercing assigned values to strings (`env.X = 5` stays a
// number). Vitest reuses a worker across files, so every later test would inherit the plain object.
const saved = BRANCH_ENV_KEYS.map((key) => [key, process.env[key]] as const)
Object.assign(process.env, {
ORCA_DEV_DOCK_TITLE: dockTitle,
ORCA_DEV_BRANCH: branch,
ORCA_DEV_INSTANCE_LABEL: branch,
ORCA_DEV_WORKTREE_NAME: branch
})
try {
return [...getDevBundlePlistPatches(), ...getDevHelperPlistPatches()]
} finally {
for (const [key, value] of saved) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
}
describe('dev-electron-bundle-identity', () => {
it('leaves process.env untouched, including its object identity', () => {
const envBefore = process.env
const snapshot = { ...process.env }
patchesUnder('Orca: some-branch', 'some-branch')
expect(process.env).toBe(envBefore)
expect({ ...process.env }).toEqual(snapshot)
})
it('patches a stable bundle id for the app and its helper', () => {
expect(getDevHelperPlistPatches()).toEqual([
{ key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID }
])
expect(DEV_HELPER_BUNDLE_ID.startsWith(`${DEV_BUNDLE_ID}.`)).toBe(true)
})
it('gives the dev app a legible display name so notifications are not just "Electron"', () => {
const byKey = Object.fromEntries(
getDevBundlePlistPatches().map((patch) => [patch.key, patch.value])
)
expect(byKey.CFBundleName).toBe(DEV_BUNDLE_DISPLAY_NAME)
expect(byKey.CFBundleDisplayName).toBe(DEV_BUNDLE_DISPLAY_NAME)
expect(DEV_BUNDLE_DISPLAY_NAME).not.toBe('Electron')
})
it('keeps the bundle display name in step with the name safeStorage keys off', () => {
// Two independently hardcoded 'Orca Dev' strings: this one names the bundle (notifications,
// System Settings), and getDevInstanceIdentity().appName drives app.setName, which decides the
// Keychain service name. Drift would split the two without anything else failing.
expect(DEV_BUNDLE_DISPLAY_NAME).toBe(getDevInstanceIdentity(true, {}).appName)
})
it('produces byte-identical patches on two different branches', () => {
// The invariant the whole fix rests on. Info.plist is inside the signature seal, so any
// branch-derived value moves the ad-hoc cdhash — and macOS Keychain ACLs match on that cdhash,
// which is what made every branch re-prompt for a password.
//
// Compared across two simulated branch environments rather than pattern-matched against
// suspicious substrings: a denylist only catches branches whose names happen to contain the
// banned words, and would miss the likeliest regression of all — re-adding
// `{ key: 'CFBundleName', value: title }` for an ordinary branch like "fix-login-crash".
expect(patchesUnder('Orca: fix-login-crash', 'fix-login-crash')).toEqual(
patchesUnder('Orca: perf-2', 'perf-2')
)
expect(patchesUnder('Orca: dev', 'main')).toEqual(
patchesUnder('Orca: some-worktree @ feature/x', 'feature/x')
)
})
it('leaks no branch, worktree, or title text into any patched value', () => {
const branch = 'fix-login-crash'
const worktree = 'Orca-safe-storage-lock'
for (const patch of patchesUnder(`Orca: ${branch}`, branch)) {
expect(patch.value).not.toContain(branch)
expect(patch.value).not.toContain(worktree)
expect(typeof patch.value).toBe('string')
expect(patch.value.length).toBeGreaterThan(0)
}
})
})
@@ -75,6 +75,25 @@ describe('electron-builder config', () => {
expect(packs('out/main/examples/index.js')).toBe(true)
})
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
it('keeps cached dev Electron bundles out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const devBundlePath of [
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
]) {
expect(packs(devBundlePath)).toBe(false)
}
// The real build outputs sit beside it under out/ and must still ship.
expect(packs('out/main/index.js')).toBe(true)
expect(packs('out/renderer/index.html')).toBe(true)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
@@ -6,6 +6,11 @@ import { tmpdir } from 'node:os'
import { EventEmitter } from 'node:events'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import {
DEV_BUNDLE_ID,
DEV_HELPER_BUNDLE_ID,
getDevHelperPlistPatches
} from './dev-electron-bundle-identity.mjs'
import {
BOOTSTRAP_FATAL_LOG_ENV_VAR,
BOOTSTRAP_FATAL_LOG_FILE_NAME,
@@ -201,10 +206,13 @@ describe('Electron Vite output contract', () => {
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
// Asserted on the values rather than the source text: the ids moved into
// dev-electron-bundle-identity.mjs so every dev bundle signs to one cdhash.
expect(DEV_HELPER_BUNDLE_ID).toBe(`${DEV_BUNDLE_ID}.helper`)
expect(getDevHelperPlistPatches()).toEqual([
{ key: 'CFBundleIdentifier', value: DEV_HELPER_BUNDLE_ID }
])
expect(devRunner).toContain("'Electron Helper.app',")
expect(devRunner).toContain(
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
)
expect(devRunner).toContain('setPlistValue(helperPlistPath, key, value)')
})
})
@@ -10,6 +10,9 @@ describe('Git binary compatibility PR gate', () => {
)
expect(step?.run).toContain('git-2.25.5.tar.gz')
// Why asserted: the sha256 check only runs on the build path, so a cached binary
// must come from a key that pins the same version the tarball line declares.
expect(step?.run).toContain('if [ ! -x "$source/git" ]; then')
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
@@ -20,4 +23,21 @@ describe('Git binary compatibility PR gate', () => {
expect(step?.run).toContain('pids+=("$!")')
expect(step?.run).toContain('wait "$pid" || status=1')
})
it('restores the baseline Git build before the matrix runs', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const steps = workflow.jobs.git_compatibility.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache baseline Git build')
const matrixIndex = steps.findIndex(
(step) => step.name === 'Verify Git binary compatibility matrix'
)
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(matrixIndex)
// The cached path and the build path must be the same directory or the guard
// above would rebuild on every run while still reporting a cache hit.
expect(steps[cacheIndex].with.path).toBe('~/.cache/orca-git-compat/git-2.25.5')
expect(steps[matrixIndex].run).toContain('source="$HOME/.cache/orca-git-compat/git-2.25.5"')
expect(steps[cacheIndex].with.key).toContain('2.25.5')
})
})
@@ -15,8 +15,9 @@ import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-co
import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation'
import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status'
import {
matchingNativeChatUserTexts,
selectPendingIndicesRepresentedByUserTexts
matchingNativeChatUserRows,
selectPendingIndicesRepresentedByUserRows,
type NativeChatUserRow
} from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence'
import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending'
import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler'
@@ -36,6 +37,7 @@ let sessionSink: NativeChatSession | null = null
let messageArraySink: NativeChatMessage[] | null = null
let contentSink = ''
let pendingMatchSink: Set<number> | null = null
let userRowSink: readonly NativeChatUserRow[] | null = null
const benchmarkStartedAt = performance.now()
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
@@ -94,10 +96,10 @@ function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSe
}
function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] {
pendingMatchSink = selectPendingIndicesRepresentedByUserTexts(
[],
matchingNativeChatUserTexts(messages)
)
// An empty queue makes the renderer skip candidate-row construction entirely, so the
// row scan is the whole cost here — keep it escaping and let the matcher take its exit.
userRowSink = matchingNativeChatUserRows(messages)
pendingMatchSink = selectPendingIndicesRepresentedByUserRows([], [])
return []
}
@@ -328,6 +330,7 @@ strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape
strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape')
strictEqual(contentSink.length > 0, true, 'message content did not escape')
strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape')
strictEqual(Array.isArray(userRowSink), true, 'user row scan did not escape')
console.log(
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
performance.now() - benchmarkStartedAt
+49 -1
View File
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
@@ -125,6 +125,54 @@ describe('PR E2E gate contract', () => {
expect(filterStep.run).toContain('set -euo pipefail')
})
it('maps SSH source edits onto the Docker-backed specs they can break', () => {
// Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only
// trigger used to be "someone edited a spec" — four pane-restore regressions shipped
// through that hole. Each mapped spec must exist, or the lane runs an empty file list.
const sshSourceAuthorities = [
'src/main/ssh/',
'src/main/providers/ssh-',
'src/main/ipc/(ssh-|pty)',
'src/relay/',
'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
]
for (const authority of sshSourceAuthorities) {
expect(filterStep.run).toContain(authority)
}
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
for (const spec of mappedSpecs) {
expect(filterStep.run).toContain(`'${spec}'`)
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
// Why: a spec that stops reading the flag would silently run without Docker.
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
expect(readFileSync(join(projectDir, spec), 'utf8'), spec).toContain('ORCA_E2E_SSH_DOCKER')
}
}
// Why: unit tests next to the mapped sources prove the same code without Docker.
expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
// meaningful while it still routes the dedicated Docker lane.
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toContain(
'tests/e2e/ssh-startup-exec-readiness.spec.ts'
)
// Why: this lane can now pay a Docker image build plus serial SSH specs.
expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(45)
const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) =>
step.name.startsWith('Install native build')
)
expect(changedInstall.run).toContain('openssh-client')
})
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
+51 -1
View File
@@ -4,7 +4,11 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { listPullFiles, nextLink } from '../../.github/scripts/pr-test-loc-summary.mjs'
import {
listPullFiles,
nextLink,
updatePullRequest
} from '../../.github/scripts/pr-test-loc-summary.mjs'
import {
LOC_HANDS_OFF_COMMENT,
isTestPath,
@@ -46,6 +50,17 @@ describe('PR test LoC summary', () => {
expect(isTestPath('src/main/foo.ts')).toBe(false)
})
it('classifies __fixtures__ and __snapshots__ data of any extension', () => {
expect(isTestPath('src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshrc.txt')).toBe(
true
)
expect(isTestPath('src/shared/__fixtures__/trace.json')).toBe(true)
expect(isTestPath('src/main/runtime/orchestration/__snapshots__/run.test.ts.snap')).toBe(true)
// Why: only the __-wrapped names count; a bare `fixtures` dir stays prod.
expect(isTestPath('src/main/daemon/fixtures/ratatui-tui.py')).toBe(false)
expect(isTestPath('src/main/my__fixtures__helper.ts')).toBe(false)
})
it('sums GitHub pull-file additions and deletions', () => {
const totals = sumChangedFiles([
{ filename: 'src/app.ts', additions: 4, deletions: 1 },
@@ -106,6 +121,41 @@ describe('PR test LoC summary', () => {
})
})
it('retries transient GitHub errors when updating the PR body', async () => {
const responses = [
{ ok: false, status: 503, statusText: 'Service Unavailable' },
{ ok: true, status: 200, statusText: 'OK' }
]
const requests = []
const result = await updatePullRequest({
owner: 'stablyai',
repo: 'orca',
pullNumber: 14656,
token: 'test-token',
totals: {
test: { files: 1, added: 2, deleted: 0 },
nonTest: { files: 0, added: 0, deleted: 0 }
},
fetchImpl: async (url, options) => {
requests.push({ url, options })
if (requests.length === 1) {
return {
ok: true,
status: 200,
json: async () => ({ body: '## Description\n' })
}
}
return responses.shift()
},
sleepImpl: async () => {}
})
expect(result).toBe(0)
expect(requests).toHaveLength(3)
expect(requests[1].options.method).toBe('PATCH')
expect(requests[2].options.method).toBe('PATCH')
})
it('colors added cells green and deleted or negative-net cells red', () => {
const block = renderLocBlock({
test: { files: 1, added: 2, deleted: 5 },
@@ -15,7 +15,9 @@ const shellContractFiles = [
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
@@ -96,6 +98,37 @@ describe('PR workflow parallelism', () => {
}
})
it('refreshes the apt index once while adding the fish PPA', () => {
const installStep = workflow.jobs.shell_contracts.steps.find(
(step) => step.name === 'Install zsh and fish'
)
// Comment lines mention both commands by name, so count the executed ones only.
const commands = installStep.run
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.join('\n')
const updates = commands.match(/apt-get update/g) ?? []
// Anchored to the start of a line so the retry message that names the command in
// prose is not mistaken for an invocation of it.
const addRepoCalls = commands
.split('\n')
.map((line) => line.trim())
.filter((line) => /^(sudo\s+)?add-apt-repository\b/.test(line))
// add-apt-repository refreshes every configured repo unless told not to, so an
// update on each side of it made this step pay for three full passes.
expect(updates).toHaveLength(1)
expect(addRepoCalls.length).toBeGreaterThan(0)
for (const call of addRepoCalls) {
expect(call.split(/\s+/)).toContain('-n')
}
// The one remaining update has to come after the PPA is on the list, or the fish
// index it exists to fetch would not be there yet.
expect(commands.lastIndexOf('add-apt-repository')).toBeLessThan(
commands.indexOf('apt-get update')
)
})
it('keeps every real-zsh test in the dedicated shell lane', () => {
const discoveredFiles = globSync(testFilePatterns)
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
@@ -171,7 +204,12 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
@@ -184,7 +222,12 @@ describe('PR workflow parallelism', () => {
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
// Why frozen: re-resolving the graph costs a minute per job and the `git diff`
// guard below already fails the run when the lockfile is stale, so the slow
// resolution can never legitimately change anything.
expect(dependencyInstall.run).toContain('--frozen-lockfile')
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
@@ -211,12 +254,63 @@ describe('PR workflow parallelism', () => {
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
})
it('restores compiled native modules after the install that strips them', () => {
const steps = dependencyAction.runs.steps
const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules')
const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime')
// `--ignore-scripts` leaves no build/, so a restore before the install would be
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
})
it('reuses TypeScript incremental state across typecheck runs', () => {
const steps = workflow.jobs.typecheck.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(checkIndex)
expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo')
// Why restore-keys matter here: an exact-key miss is the normal case (the key is
// per-SHA), so without them the cache would never once be read.
expect(steps[cacheIndex].with['restore-keys']).toBeTruthy()
// The buildinfo is only reusable while the compiler options that produced it hold.
expect(steps[cacheIndex].with.key).toContain(
"hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')"
)
})
it('checks out full history without historical blobs', () => {
const fullHistoryCheckouts = Object.values(workflow.jobs)
.flatMap((job) => job.steps ?? [])
.filter(
(step) => step.uses?.startsWith('actions/checkout@') && step.with?.['fetch-depth'] === 0
)
expect(fullHistoryCheckouts.length).toBeGreaterThan(0)
for (const checkout of fullHistoryCheckouts) {
expect(checkout.with.filter).toBe('blob:none')
}
})
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'static_analysis',
'root_directory_guard',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'managed_hook_node18',
+690
View File
@@ -0,0 +1,690 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The source patch and the emitted patch are the same edits diffed two ways, so
* they must produce the same bytes. A source hunk the emitted patch cannot carry
* would be deleted by the next `--write`, so this fails instead of shipping one.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText) {
const checkout = sourceHunks(checkoutSource)
const emitted = sourceHunks(patchText)
if (checkout === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a source file.',
` from checkout: [${splitPatchEntries(checkout)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(checkout, emitted)}`,
'',
'A hunk the emitted patch cannot name is never installed, so it cannot ship',
'here; upstream .npmignore strips `src/**/*.test.ts`.'
].join('\n')
)
}
export function stampVersionSource(source, version) {
const stamped = source.replace(
/export const XTERM_VERSION = '[^']+';/,
`export const XTERM_VERSION = '${version}';`
)
if (stamped === source && !source.includes(`'${version}'`)) {
throw new Error('Version stamp file does not declare XTERM_VERSION')
}
return stamped
}
/**
* The published tarball names the commit it was built from, so a version bump
* that forgets the manifest fails here instead of producing a patch against the
* wrong tree.
*/
export function assertPublishedCommit(publishedPackageJson, packageEntry, upstreamCommit) {
if (publishedPackageJson.version !== packageEntry.version) {
throw new Error(
`${packageEntry.name}: registry served ${publishedPackageJson.version}, manifest pins ${packageEntry.version}`
)
}
if (publishedPackageJson.commit !== upstreamCommit) {
throw new Error(
[
`${packageEntry.name}@${packageEntry.version} was published from commit`,
` ${publishedPackageJson.commit ?? '(absent)'}`,
`but ${MANIFEST_RELATIVE_PATH} pins`,
` ${upstreamCommit}`,
'Update upstream.commit in the manifest to the published commit, then rerun with --write.'
].join('\n')
)
}
}
/** Guards the publish-order trap: a dev esbuild pass would silently de-minify lib/*.mjs. */
export function assertBuildStepsAllowed(manifest) {
const forbidden = new Set(manifest.forbiddenBuildScripts?.scripts ?? [])
for (const packageEntry of manifest.packages) {
for (const step of packageEntry.build) {
const script = step.command === 'npm' && step.args[0] === 'run' ? step.args[1] : undefined
if (script !== undefined && forbidden.has(script)) {
throw new Error(
`${packageEntry.name}: build step \`npm run ${script}\` is forbidden. ${manifest.forbiddenBuildScripts.why}`
)
}
}
}
}
// `delete` was dropped with the code that implemented it: accepting a policy
// nothing honours would silently ship maps that do not match the bundle.
export const SOURCEMAP_POLICIES = new Set(['include'])
/** An unrecognised policy is a manifest bug, not a default to fall through to. */
export function assertSourcemapPolicy(manifest) {
const policy = manifest.sourcemaps?.policy
if (!SOURCEMAP_POLICIES.has(policy)) {
throw new Error(
`sourcemaps.policy must be one of ${[...SOURCEMAP_POLICIES].join(', ')}, got ${JSON.stringify(policy)}`
)
}
return policy
}
/**
* pnpm keys the patched package directory and the lockfile entry by the
* sha256 of the patch file itself, so a regenerated patch that leaves
* pnpm-lock.yaml alone fails `--frozen-lockfile` on every machine but the
* author's.
*/
export function patchHash(patchText) {
return createHash('sha256').update(patchText, 'utf8').digest('hex')
}
function lockfilePatchHashPattern(packageKey) {
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
}
export function readLockfilePatchHash(lockfileText, packageKey) {
const match = lockfilePatchHashPattern(packageKey).exec(lockfileText)
if (!match) {
throw new Error(`pnpm-lock.yaml has no patchedDependencies entry for '${packageKey}'`)
}
return match[2]
}
function lockfileResolutionHashPattern(packageKey) {
const separator = packageKey.lastIndexOf('@')
const name = escapeRegExp(packageKey.slice(0, separator))
const version = escapeRegExp(packageKey.slice(separator + 1))
// Two spellings: `name@version(patch_hash=…)` in dependency keys, and a bare
// `: version(patch_hash=…)` under `version:` and in resolved dependency maps.
return new RegExp(`(?:${name}@|: )${version}\\(patch_hash=([0-9a-f]{64})\\)`, 'g')
}
/**
* pnpm repeats the hash inside every resolution key that depends on the patched
* package, not just in `patchedDependencies`. Updating one and not the other leaves
* a lockfile that installs on a warm store and drifts on a cold one, which is CI.
*/
export function readLockfileResolutionHashes(lockfileText, packageKey) {
return Array.from(
lockfileText.matchAll(lockfileResolutionHashPattern(packageKey)),
(match) => match[1]
)
}
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
return (
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
readLockfileResolutionHashes(lockfileText, packageKey).some((value) => value !== hash)
)
}
export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
readLockfilePatchHash(lockfileText, packageKey)
return lockfileText
.replace(lockfilePatchHashPattern(packageKey), `$1${hash}`)
.replace(lockfileResolutionHashPattern(packageKey), (match, current) =>
match.replace(`patch_hash=${current}`, `patch_hash=${hash}`)
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
// npm ships as `npm.cmd` on Windows, and execFile applies no PATHEXT and refuses
// a `.cmd` target without a shell (CVE-2024-27980). git and tar are real .exe.
const WINDOWS_SHIM_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn'])
function run(command, args, options = {}) {
const shim = process.platform === 'win32' && WINDOWS_SHIM_COMMANDS.has(command)
return execFileSync(shim ? `${command}.cmd` : command, args, {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'inherit'],
shell: shim,
...options
})
}
function listFilesRelative(root, base = root) {
const files = []
for (const entry of readdirSync(base, { withFileTypes: true })) {
const absolute = path.join(base, entry.name)
if (entry.isDirectory()) {
files.push(...listFilesRelative(root, absolute))
} else if (entry.isFile()) {
files.push(path.relative(root, absolute))
}
}
return files.sort()
}
function sameBytes(left, right) {
return (
statSync(left).size === statSync(right).size && readFileSync(left).equals(readFileSync(right))
)
}
function fetchPristinePackage(packageEntry, workDir) {
const target = path.join(workDir, 'pristine', packageEntry.name.replace(/[@/]/g, '_'))
rmSync(target, { recursive: true, force: true })
mkdirSync(target, { recursive: true })
const spec = `${packageEntry.name}@${packageEntry.version}`
const output = run('npm', ['pack', spec, '--pack-destination', target, '--silent'], {
cwd: workDir
})
const tarball = path.join(target, output.trim().split('\n').at(-1).trim())
run('tar', ['xzf', tarball, '-C', target])
return path.join(target, 'package')
}
function hasCommit(root, commit) {
try {
return run('git', ['cat-file', '-t', commit], { cwd: root, stdio: 'pipe' }).trim() === 'commit'
} catch {
return false
}
}
function ensureUpstreamCheckout(manifest, workDir) {
const root = path.join(workDir, 'upstream')
const { repository, commit } = manifest.upstream
if (!existsSync(path.join(root, '.git'))) {
mkdirSync(root, { recursive: true })
run('git', ['init', '--quiet'], { cwd: root })
run('git', ['remote', 'add', 'origin', repository], { cwd: root })
}
if (!hasCommit(root, commit)) {
run('git', ['fetch', '--depth=1', 'origin', commit], { cwd: root, stdio: 'inherit' })
}
run('git', ['checkout', '--quiet', '--detach', commit], { cwd: root })
run('git', ['reset', '--quiet', '--hard', commit], { cwd: root })
return root
}
function ensureDependencies(upstreamRoot, manifest) {
const lockfile = path.join(upstreamRoot, 'package-lock.json')
const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp')
const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n`
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) {
return
}
run('npm', ['ci'], {
cwd: upstreamRoot,
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', PUPPETEER_SKIP_DOWNLOAD: '1' }
})
assertToolchain(upstreamRoot, manifest)
writeFileSync(stamp, want)
}
function assertToolchain(upstreamRoot, manifest) {
const expected = manifest.toolchain
for (const [name, version] of Object.entries(expected)) {
if (name === 'why') {
continue
}
const installed = path.join(upstreamRoot, 'node_modules', name, 'package.json')
if (!existsSync(installed)) {
throw new Error(
`Upstream install is missing ${name}. The pinned toolchain is no longer resolvable; see the tsgo note in docs/reference/xterm-patch-regeneration.md.`
)
}
const actual = JSON.parse(readFileSync(installed, 'utf8')).version
if (actual !== version) {
throw new Error(
`Upstream ${name} resolved to ${actual}, manifest expects ${version}. Update the toolchain block only together with a verified rebuild.`
)
}
}
}
/**
* The published src/ must equal the pinned commit's src/ apart from the version
* stamp publish.js rewrites. If it does not, the manifest points at the wrong
* commit and every hunk below would be nonsense.
*/
function assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry) {
const stampFile = packageEntry.versionStampFile
const sourceRoot = path.join(pristineDir, 'src')
const drifted = listFilesRelative(sourceRoot)
.map((relative) => path.join('src', relative))
.filter((relative) => relative !== stampFile)
.filter(
(relative) =>
!sameBytes(
path.join(pristineDir, relative),
path.join(upstreamRoot, packageEntry.packageDir, relative)
)
)
if (drifted.length > 0) {
throw new Error(
`Published src/ does not match ${packageEntry.packageDir} at the pinned commit: ${drifted.join(', ')}`
)
}
}
function buildPackage(upstreamRoot, packageEntry, manifest) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const directory of ['lib', 'out', 'out-esbuild']) {
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
}
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
assertBuildStepsAllowed(manifest)
for (const step of packageEntry.build) {
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
}
}
/** Proves the pinned toolchain still reproduces the untouched published bundles. */
function assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
const drifted = listFilesRelative(pristineDir)
.filter((relative) =>
packageEntry.generatedPaths.some((prefix) => toPosix(relative).startsWith(prefix))
)
.filter(
(relative) => !sameBytes(path.join(pristineDir, relative), path.join(packageRoot, relative))
)
if (drifted.length > 0) {
throw new Error(
[
`Rebuilding ${packageEntry.name}@${packageEntry.version} from the pinned commit did not reproduce the published bundles:`,
...drifted.map((relative) => ` ${relative}`),
'',
'Refusing to emit a patch. Either the toolchain drifted or the build ran in the',
'wrong order (a dev `npm run setup` pass de-minifies lib/*.mjs).'
].join('\n')
)
}
}
function toPosix(value) {
return value.split(path.sep).join('/')
}
function overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, destination) {
rmSync(destination, { recursive: true, force: true })
cpSync(pristineDir, destination, { recursive: true })
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const relative of listFilesRelative(pristineDir)) {
// package.json carries the registry's version/commit stamp, which the build
// tree has no way to reproduce and which we never want to patch.
if (relative === 'package.json') {
continue
}
const built = path.join(packageRoot, relative)
if (!existsSync(built)) {
throw new Error(`Published file has no build-tree counterpart: ${relative}`)
}
copyFileSync(built, path.join(destination, relative))
}
}
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
maxBuffer: 512 * 1024 * 1024,
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
// `git diff --no-index` exits 1 whenever it finds differences.
if (error.status !== 1 || error.stderr?.length > 0) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
/** The source of truth for the hand-written half: what the checkout itself holds. */
function diffCheckoutSource(packageRoot) {
return run('git', [...CHECKOUT_DIFF_FLAGS, '--', 'src/'], {
cwd: packageRoot,
env: pnpmDiffEnvironment(),
maxBuffer: 64 * 1024 * 1024
})
}
function regeneratePackage(packageEntry, manifest, context) {
const { workDir, repoRoot } = context
const pristineDir = fetchPristinePackage(packageEntry, workDir)
const published = JSON.parse(readFileSync(path.join(pristineDir, 'package.json'), 'utf8'))
assertPublishedCommit(published, packageEntry, manifest.upstream.commit)
const upstreamRoot = ensureUpstreamCheckout(manifest, workDir)
ensureDependencies(upstreamRoot, manifest)
assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry)
buildPackage(upstreamRoot, packageEntry, manifest)
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
buildPackage(upstreamRoot, packageEntry, manifest)
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, patchedDir)
// Leave the checkout diffable: the pinned commit plus the source patch, with
// no publish-time version stamp mixed in, so `git diff` there is the source
// patch and nothing else.
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
const patch = diffFolders(pristineDir, patchedDir)
assertSourceDerivationsAgree(source, patch)
return { patch, source }
}
export function regenerateXtermPatches({
mode,
repoRoot = DEFAULT_REPO_ROOT,
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
log = console.info
} = {}) {
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
assertBuildStepsAllowed(manifest)
assertSourcemapPolicy(manifest)
mkdirSync(workDir, { recursive: true })
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
let lockfile = readFileSync(lockfilePath, 'utf8')
let lockfileChanged = false
const failures = []
for (const packageEntry of manifest.packages) {
const shortCommit = manifest.upstream.commit.slice(0, 12)
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
const { patch: regenerated, source: canonicalSource } = regeneratePackage(
packageEntry,
manifest,
{ workDir, repoRoot }
)
const patchPath = path.join(repoRoot, packageEntry.patch)
const sourcePatchPath = path.join(repoRoot, packageEntry.sourcePatch)
const packageKey = `${packageEntry.name}@${packageEntry.version}`
const hash = patchHash(regenerated)
if (mode === 'write') {
writeFileSync(patchPath, regenerated)
writeFileSync(sourcePatchPath, canonicalSource)
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
lockfileChanged = true
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
}
continue
}
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
const stale = Array.from(
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
)
failures.push(
[
`${packageKey}: pnpm-lock.yaml records a stale patch hash.`,
` patchedDependencies: ${readLockfilePatchHash(lockfile, packageKey)}`,
` resolution keys: ${stale.length > 0 ? stale.join(', ') : 'in sync'}`,
` patch: ${hash}`,
'',
'pnpm keys the patched package by the sha256 of the patch file, so',
'`pnpm install --frozen-lockfile` will fail. Rerun with --write.'
].join('\n')
)
}
const committed = readFileSync(patchPath, 'utf8')
if (committed !== regenerated) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.patch,
committed,
regenerated
})
)
continue
}
const committedSource = readFileSync(sourcePatchPath, 'utf8')
if (committedSource !== canonicalSource) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.sourcePatch,
committed: committedSource,
regenerated: canonicalSource
})
)
continue
}
log(` in sync (${Buffer.byteLength(regenerated)} bytes)`)
}
if (lockfileChanged) {
writeFileSync(lockfilePath, lockfile)
}
if (failures.length > 0) {
throw new Error(failures.join('\n\n'))
}
}
const USAGE =
'Usage: regenerate-xterm-patches.mjs [--check | --write] [--work-dir=<path>]\n' +
' --check (default) verifies the shipped patches match the pinned upstream build;\n' +
' --write regenerates them from config/patches/xterm-src/. Build outside this repo:\n' +
' tsc otherwise walks up into our node_modules. See\n' +
' docs/reference/xterm-patch-regeneration.md.'
function main(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
console.info(USAGE)
return
}
// --check is the default, so an unrecognised flag would otherwise silently run a full
// upstream build instead of whatever the caller meant.
const known = (v) =>
!v.startsWith('-') || ['--write', '--check'].includes(v) || v.startsWith('--work-dir=')
const unknown = argv.filter((value) => !known(value))
if (unknown.length > 0) {
throw new Error(`Unknown option: ${unknown.join(', ')}\n${USAGE}`)
}
const write = argv.includes('--write')
const check = argv.includes('--check') || !write
if (write && argv.includes('--check')) {
throw new Error('Pass either --write or --check, not both')
}
const workDirArgument = argv.find((value) => value.startsWith('--work-dir='))
regenerateXtermPatches({
mode: write ? 'write' : 'check',
workDir: workDirArgument ? path.resolve(workDirArgument.slice('--work-dir='.length)) : undefined
})
if (check) {
console.info('xterm patches are in sync with the pinned upstream build.')
}
}
// realpathSync so a symlinked checkout path still registers as a direct run.
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
try {
main(process.argv.slice(2))
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
@@ -0,0 +1,463 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertBuildStepsAllowed,
assertPublishedCommit,
assertSourceDerivationsAgree,
assertSourcemapPolicy,
firstDifferenceIndex,
formatCheckFailure,
lockfilePatchHashIsStale,
normalizePnpmDiff,
patchHash,
pnpmDiffEnvironment,
readLockfilePatchHash,
readLockfileResolutionHashes,
sourceHunks,
splitPatchEntries,
stampVersionSource,
updateLockfilePatchHash
} from './regenerate-xterm-patches.mjs'
// Only the tests need to slice the generated half out of a patch; the generator
// reads `generatedPaths` directly where it compares against the pristine build.
function generatedHunks(patchText, generatedPaths) {
return splitPatchEntries(patchText)
.filter((entry) => generatedPaths.some((prefix) => entry.path.startsWith(prefix)))
.map((entry) => entry.text)
.join('')
}
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_PATH = path.join(REPO_ROOT, 'config', 'patches', 'xterm-upstream.json')
const temporaryDirectories = []
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
async function createDirectory() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-xterm-patch-'))
temporaryDirectories.push(directory)
return directory
}
async function writeTree(root, files) {
for (const [relative, contents] of Object.entries(files)) {
const target = path.join(root, relative)
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, contents)
}
}
/** The three exported diff pieces, composed the way the generator composes them. */
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
if (error.status !== 1) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
const PRISTINE = {
'src/Widget.ts': 'export function widget(): number {\n return 1\n}\n',
'src/Other.ts': 'export const other = 0\n',
'lib/widget.js': 'function widget(){return 1}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAA"}\n',
'package.json': '{\n "name": "@scope/widget"\n}\n'
}
const PATCHED = {
...PRISTINE,
'src/Widget.ts': 'export function widget(): number {\n return 2\n}\n',
'lib/widget.js': 'function widget(){return 2}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAC"}\n'
}
describe('pnpm diff format', () => {
it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => {
expect(PNPM_DIFF_FLAGS).toEqual([
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
])
})
it('blanks the config-bearing environment variables', () => {
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
expect(environment).toMatchObject({
PATH: '/usr/bin',
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
})
})
it('strips both scratch folder prefixes from headers and index lines', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(patch).not.toContain(root)
expect(patch).toContain('diff --git a/lib/widget.js b/lib/widget.js')
expect(patch).toContain('--- a/src/Widget.ts')
expect(patch).toContain('+++ b/src/Widget.ts')
})
it('drops a trailing no-newline marker and .DS_Store entries', () => {
const withMarker = 'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n'
expect(normalizePnpmDiff(withMarker, '/a', '/b')).toBe(
'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n'
)
const withJunk = [
'diff --git a/.DS_Store b/.DS_Store\n',
'index 000..111\n',
'Binary files differ\n',
'diff --git a/lib/x.js b/lib/x.js\n',
'@@ -1 +1 @@\n-a\n+b\n'
].join('')
expect(normalizePnpmDiff(withJunk, '/a', '/b')).toBe(
'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n'
)
})
})
describe('patch entry splitting', () => {
it('separates hand-edited source hunks from generated bundle hunks', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(splitPatchEntries(patch).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map',
'src/Widget.ts'
])
expect(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path)).toEqual([
'src/Widget.ts'
])
expect(splitPatchEntries(generatedHunks(patch, ['lib/'])).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map'
])
})
it('rejects renames rather than emitting a header it cannot round-trip', () => {
expect(() => splitPatchEntries('diff --git a/old.ts b/new.ts\n')).toThrow(
/renames are not supported/
)
})
it('concatenating the two halves reproduces the whole patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(generatedHunks(patch, ['lib/']) + sourceHunks(patch)).toBe(patch)
})
})
describe('round-trip stability', () => {
it('re-diffing an applied patch yields the identical patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
const patchFile = path.join(root, 'round-trip.patch')
await writeFile(patchFile, patch)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PATCHED['lib/widget.js']
)
expect(diffFolders(folderA, replay)).toBe(patch)
})
it('applying only the source half leaves the bundle untouched', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patchFile = path.join(root, 'src.patch')
await writeFile(patchFile, sourceHunks(diffFolders(folderA, folderB)))
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'src/Widget.ts'), 'utf8')).toBe(
PATCHED['src/Widget.ts']
)
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PRISTINE['lib/widget.js']
)
})
})
// `--write` rewrites the source patch from the emitted patch, so a hunk the
// emitted patch cannot name would delete itself on the next run.
describe('source derivation agreement', () => {
const publishedEntry = [
'diff --git a/src/browser/Types.ts b/src/browser/Types.ts',
'index 1111111..2222222 100644',
'--- a/src/browser/Types.ts',
'+++ b/src/browser/Types.ts',
'@@ -1 +1,2 @@',
' interface ICompositionHelper {',
'+ handleCompositionInput(data: string): boolean;',
''
].join('\n')
const unpublishedEntry = [
'diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts',
'index 3333333..4444444 100644',
'--- a/src/browser/TestUtils.test.ts',
'+++ b/src/browser/TestUtils.test.ts',
'@@ -1 +1,2 @@',
' class MockCompositionHelper {',
'+ public handleCompositionInput(): boolean { return false; }',
''
].join('\n')
it('fails when the source patch carries a file the emitted patch cannot', () => {
expect(() =>
assertSourceDerivationsAgree(publishedEntry + unpublishedEntry, publishedEntry)
).toThrow(/disagree on a source file/)
})
it('fails when the two derivations disagree on a file', () => {
expect(() =>
assertSourceDerivationsAgree(publishedEntry.replace('boolean;', 'void;'), publishedEntry)
).toThrow(/disagree on a source file/)
})
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
})
})
describe('manifest guards', () => {
const packageEntry = { name: '@xterm/xterm', version: '6.1.0-beta.287' }
const commit = '53a98a720ae4a973e384fa2440880d09537132f3'
it('accepts a tarball that names the pinned commit', () => {
const published = { version: '6.1.0-beta.287', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).not.toThrow()
})
it('fails when a version bump moved the upstream commit', () => {
const published = { version: '6.1.0-beta.287', commit: 'f'.repeat(40) }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(
/was published from commit[\s\S]*Update upstream\.commit/
)
})
it('fails when the registry serves a different version than the manifest pins', () => {
const published = { version: '6.1.0-beta.288', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(/registry served/)
})
it('fails when the tarball carries no commit stamp at all', () => {
expect(() =>
assertPublishedCommit({ version: '6.1.0-beta.287' }, packageEntry, commit)
).toThrow(/\(absent\)/)
})
it('refuses a build step that would de-minify the bundle', () => {
const manifest = {
forbiddenBuildScripts: { why: 'dev esbuild', scripts: ['setup'] },
packages: [
{
name: '@xterm/xterm',
build: [
{ cwd: '.', command: 'npm', args: ['run', 'setup'] },
{ cwd: '.', command: 'npm', args: ['run', 'package'] }
]
}
]
}
expect(() => assertBuildStepsAllowed(manifest)).toThrow(/`npm run setup` is forbidden/)
})
it('refuses a sourcemap policy it does not implement', () => {
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'include' } })).toBe('include')
// `delete` named a code path that no longer exists; accepting it would ship
// maps that do not match the bundle.
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'delete' } })).toThrow(
/must be one of include, got "delete"/
)
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'exclude' } })).toThrow(
/must be one of include, got "exclude"/
)
expect(() => assertSourcemapPolicy({})).toThrow(/got undefined/)
})
it('stamps the published version into the version source', () => {
const source = "export const XTERM_VERSION = '6.0.0';\n"
expect(stampVersionSource(source, '6.1.0-beta.287')).toBe(
"export const XTERM_VERSION = '6.1.0-beta.287';\n"
)
expect(() => stampVersionSource('export const OTHER = 1\n', '6.1.0')).toThrow(/XTERM_VERSION/)
})
})
describe('lockfile coupling', () => {
const lockfile = [
'patchedDependencies:',
" '@xterm/xterm@6.1.0-beta.287':",
` hash: ${'0'.repeat(64)}`,
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
' node-pty@1.1.0:',
` hash: ${'1'.repeat(64)}`,
' path: config/patches/node-pty@1.1.0.patch',
'snapshots:',
` '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=${'0'.repeat(64)}))':`,
` '@xterm/xterm': 6.1.0-beta.287(patch_hash=${'0'.repeat(64)})`,
` node-pty@1.1.0(patch_hash=${'1'.repeat(64)}):`,
''
].join('\n')
it('hashes the patch the way pnpm keys the store directory', () => {
expect(patchHash('diff --git a/x b/x\n')).toBe(
createHash('sha256').update('diff --git a/x b/x\n').digest('hex')
)
})
it('reads quoted and unquoted package keys', () => {
expect(readLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe('0'.repeat(64))
expect(readLockfilePatchHash(lockfile, 'node-pty@1.1.0')).toBe('1'.repeat(64))
})
it('rewrites only the targeted entry', () => {
const updated = updateLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287', 'a'.repeat(64))
expect(readLockfilePatchHash(updated, '@xterm/xterm@6.1.0-beta.287')).toBe('a'.repeat(64))
expect(readLockfilePatchHash(updated, 'node-pty@1.1.0')).toBe('1'.repeat(64))
expect(updated.split('\n')).toHaveLength(lockfile.split('\n').length)
})
// pnpm repeats the hash in every resolution key. Rewriting only patchedDependencies
// installs fine on a warm store and drifts on a cold one, so it fails in CI only.
it('rewrites the resolution keys as well as patchedDependencies', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
expect(readLockfileResolutionHashes(lockfile, key)).toEqual(['0'.repeat(64), '0'.repeat(64)])
const updated = updateLockfilePatchHash(lockfile, key, 'a'.repeat(64))
expect(readLockfileResolutionHashes(updated, key)).toEqual(['a'.repeat(64), 'a'.repeat(64)])
expect(readLockfileResolutionHashes(updated, 'node-pty@1.1.0')).toEqual(['1'.repeat(64)])
expect(updated).not.toContain('0'.repeat(64))
})
it('reports a lockfile stale in its resolution keys alone', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
const halfUpdated = lockfile.replace(`hash: ${'0'.repeat(64)}`, `hash: ${'a'.repeat(64)}`)
expect(readLockfilePatchHash(halfUpdated, key)).toBe('a'.repeat(64))
expect(lockfilePatchHashIsStale(halfUpdated, key, 'a'.repeat(64))).toBe(true)
expect(lockfilePatchHashIsStale(lockfile, key, '0'.repeat(64))).toBe(false)
})
it('fails loudly when the package is not patched at all', () => {
expect(() => readLockfilePatchHash(lockfile, '@xterm/addon-webgl@0.20.0-beta.286')).toThrow(
/no patchedDependencies entry/
)
})
})
describe('check-mode reporting', () => {
it('points at the source patch instead of the bundle', () => {
const message = formatCheckFailure({
name: '@xterm/xterm',
patchPath: 'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
committed: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n',
regenerated: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+c\n'
})
expect(message).toContain('Do not edit them')
expect(message).toContain('--write')
expect(message).toContain('docs/reference/xterm-patch-regeneration.md')
expect(message).toContain('files [lib/x.js]')
})
it('locates the first differing character', () => {
expect(firstDifferenceIndex('abc', 'abd')).toBe(2)
expect(firstDifferenceIndex('abc', 'abc')).toBe(-1)
expect(firstDifferenceIndex('abc', 'abcd')).toBe(3)
})
})
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const key = `${packageEntry.name}@${packageEntry.version}`
expect(readLockfilePatchHash(lockfile, key)).toBe(patchHash(patch))
}
})
it('keeps the source patch and the full patch equal on every source file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
expect(sourceHunks(source)).toBe(sourceHunks(patch))
expect(generatedHunks(patch, packageEntry.generatedPaths)).not.toBe('')
}
})
it('pins a full upstream commit and a buildable package entry', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
expect(manifest.upstream.commit).toMatch(/^[0-9a-f]{40}$/)
expect(manifest.packages.length).toBeGreaterThan(0)
expect(() => assertBuildStepsAllowed(manifest)).not.toThrow()
})
})
@@ -36,12 +36,34 @@ describe('release E2E dispatch contract', () => {
})
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
const buildStep = e2eWorkflow.jobs.build.steps.find(
(step) => step.name === 'Build Electron app for E2E'
)
const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
expect(buildStep.run).toContain('electron-vite build --mode e2e')
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
expect(buildStep.run).toContain('pnpm run build:relay')
})
it('hands the built relay artifact to every E2E run command', () => {
const uploadStep = e2eWorkflow.jobs.build.steps.find(
(step) => step.name === 'Upload E2E build output'
)
expect(uploadStep.with.name).toBe('e2e-build-out')
expect(uploadStep.with.path).toBe('out/')
for (const [jobName, runStepName] of [
['e2e', 'Run E2E tests (${{ matrix.shard_name }})'],
['changed-e2e', 'Run changed E2E specs']
]) {
const job = e2eWorkflow.jobs[jobName]
const downloadStep = job.steps.find((step) => step.name === 'Download E2E build output')
const runStep = job.steps.find((step) => step.name === runStepName)
expect(job.needs).toBe('build')
expect(downloadStep.with.name).toBe('e2e-build-out')
expect(downloadStep.with.path).toBe('out/')
expect(runStep.run).toContain('ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay"')
}
})
})
+136 -21
View File
@@ -17,6 +17,12 @@ import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
import {
DEV_BUNDLE_ID,
getDevBundlePlistPatches,
getDevHelperPlistPatches
} from './dev-electron-bundle-identity.mjs'
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
@@ -110,6 +116,60 @@ function sanitizeMacAppBundleName(value) {
)
}
function getDevBundleProcessTable() {
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
// from being truncated. The raw text is searched directly; see dev-electron-bundle-cache.mjs
// for why it is deliberately not parsed into paths.
try {
return execFileSync('/bin/ps', ['-Awwo', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
})
} catch {
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
return null
}
}
function pruneStaleDevBundles(distDir) {
const root = path.dirname(distDir)
let bundles
try {
bundles = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, 'orca-dev-electron-app.json')),
mtimeMs: getMtimeMs(dir)
}
})
} catch {
return
}
if (bundles.length <= 1) {
return
}
const processTable = getDevBundleProcessTable()
if (processTable === null) {
return
}
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: distDir,
processTable,
nowMs: Date.now()
})
for (const dir of stale) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {}
}
}
function prepareMacDevElectronApp() {
if (process.platform !== 'darwin') {
return
@@ -128,9 +188,9 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v10: add the keyboard-layout helper. A stale copy only emits extra fields
// the parser ignores, so narrowing its schema needs no bump.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v10'
// v11: stop patching the branch title into Info.plist so every dev bundle signs to one cdhash.
// A stale copy only emits extra fields the parser ignores, so narrowing its schema needs no bump.
const bundleLayoutVersion = 'stable-cdhash-dock-name-from-bundle-dir-v11'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -138,8 +198,9 @@ function prepareMacDevElectronApp() {
.digest('hex')
.slice(0, 12)
const distDir = path.join(repoRoot, 'out', 'electron-dev', hash)
// Why: macOS Dock hover uses the bundle's filesystem display name for
// electron-vite's direct binary launch path, even when Info.plist is patched.
// Why: macOS Dock hover uses the bundle's filesystem display name for electron-vite's direct
// binary launch path. This is what carries the per-branch name now that Info.plist no longer does,
// and it sits outside the code signature, so varying it does not disturb the cdhash.
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
@@ -153,24 +214,37 @@ function prepareMacDevElectronApp() {
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
const helperBundleId = `${bundleId}.helper`
const bundleId = DEV_BUNDLE_ID
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
// Why the patches are in the marker: bundleLayoutVersion alone does not cover them, so a cache
// built before a patch value changed would be reused and keep presenting the old identity.
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
{
title,
appBundleName,
bundleId,
sourceAppPath,
electronVersion,
bundleLayoutVersion,
plistPatches: [...getDevBundlePlistPatches(), ...getDevHelperPlistPatches()]
},
null,
2
)
const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Electron')
// Split by consequence: without this Chromium blank-crashes, so it gates whether the bundle can
// run at all. The keyboard-layout helper below is optional -- swiftc builds it non-fatally, so on
// a Mac without full Xcode it is simply absent and only a keyboard feature degrades.
const chromiumResourcePath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
)
const requiredResourcePaths = [
path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
),
chromiumResourcePath,
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
]
@@ -194,6 +268,30 @@ function prepareMacDevElectronApp() {
}
if (copiedAppIsUsable()) {
pruneStaleDevBundles(distDir)
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
// Why this guard: a rebuild replaces this exact directory, and making the marker conditional on a
// successful sign means an unsigned bundle is a permanent cache miss -- so every later run would
// reach this rmSync while another instance is still running from it, deleting its app mid-session.
// Reusing what is there matches what the runner did before the marker became conditional.
const rebuildProcessTable = getDevBundleProcessTable()
if (
rebuildProcessTable !== null &&
isDevBundleInUse(distDir, rebuildProcessTable) &&
// Why a runnability check: a bundle missing Chromium's resources blank-crashes, and its
// orphaned crashpad helper still matches the process table -- so without this the runner would
// reuse a broken bundle forever and never rebuild itself out. Deliberately narrower than
// copiedAppIsUsable, which also demands the optional keyboard-layout helper: gating on that
// would strand every Mac without swiftc back on the rmSync path this guard exists to avoid.
existsSync(executablePath) &&
existsSync(chromiumResourcePath)
) {
console.warn(
`[orca-dev] Another dev instance is running from this bundle; reusing it instead of rebuilding. Quit the other instance (or delete ${distDir}) to force a rebuild.`
)
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
@@ -214,10 +312,17 @@ function prepareMacDevElectronApp() {
'Contents',
'Info.plist'
)
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
// Why every value here is constant: Info.plist is inside the signature seal, so a branch-varying
// value (these keys used to carry the branch title) changed the ad-hoc cdhash per branch, and
// macOS Keychain ACLs match on that cdhash — every branch read as a different app and re-prompted.
// Patching these keys is fine; varying them is not. The Dock takes its label from the .app
// directory name (see appBundleName), which is outside the signature, so per-branch names survive.
for (const { key, value } of getDevBundlePlistPatches()) {
setPlistValue(plistPath, key, value)
}
for (const { key, value } of getDevHelperPlistPatches()) {
setPlistValue(helperPlistPath, key, value)
}
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
@@ -268,14 +373,24 @@ function prepareMacDevElectronApp() {
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
let signed = true
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
signed = false
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
// Why only when signed: the marker is what marks this bundle reusable. Writing it after a failed
// sign cached an unsigned bundle permanently -- the warning above scrolled past once and every
// later launch silently reused it, losing notification delivery and the stable cdhash that keeps
// safeStorage from re-prompting. Leaving it unwritten costs a re-copy per launch until signing
// works, and still does not block `pnpm dev`.
if (signed) {
writeFileSync(markerPath, expectedMarker, 'utf8')
}
pruneStaleDevBundles(distDir)
process.env.ELECTRON_EXEC_PATH = executablePath
}
@@ -0,0 +1,53 @@
import { spawn } from 'node:child_process'
import { availableParallelism } from 'node:os'
import { fileURLToPath } from 'node:url'
// The three projects overlap heavily in src/shared but have no build dependency on
// each other, so tsc can check them concurrently instead of in a `&&` chain.
const projects = ['tsconfig.node.json', 'tsconfig.tc.cli.json', 'tsconfig.tc.web.json']
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
// Why serialize on a single-core runner: three tsc processes there thrash rather than overlap.
const concurrent = availableParallelism() > 1
function checkProject(project) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [tsc, '--noEmit', '-p', `config/${project}`], {
cwd: repoRoot,
stdio: 'inherit'
})
child.on('error', reject)
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`tsc ${project} exited with signal ${signal}`))
} else if (code !== 0) {
reject(new Error(`tsc ${project} exited with code ${code}`))
} else {
resolve()
}
})
})
}
let failures = []
if (concurrent) {
const results = await Promise.allSettled(projects.map(checkProject))
failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason)
} else {
for (const project of projects) {
try {
await checkProject(project)
} catch (error) {
failures.push(error)
}
}
}
if (failures.length > 0) {
for (const failure of failures) {
console.error(failure.message ?? failure)
}
process.exit(1)
}
@@ -13,7 +13,7 @@ describe('packaged skills CLI PR gates', () => {
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
function runWsl(distro, script, options = {}) {
return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
return execFileSync('wsl.exe', ['-d', distro, '--exec', 'bash', '-se'], {
cwd: rootDir,
encoding: 'utf8',
input: script,
+5 -14
View File
@@ -86,10 +86,6 @@ function wslArgs(distro, args) {
return ['-d', distro, '--exec', ...args]
}
function wslShellArgs(distro, args) {
return ['-d', distro, '--', ...args]
}
async function resolveDistro(requested) {
if (requested) {
return requested
@@ -139,16 +135,14 @@ async function main() {
assertRepoPath(options.mountedRepo, '/mnt/')
const distro = await resolveDistro(options.distro)
const jiti = createJiti(import.meta.url)
const { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } =
await jiti.import('../../src/shared/wsl-login-shell-command.ts')
const { buildWslLoginShellCommand, quotePosixShell } = await jiti.import(
'../../src/shared/wsl-login-shell-command.ts'
)
const loginProbe = buildWslLoginShellCommand(
`printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"`
)
const probe = await run(
'wsl.exe',
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(loginProbe)])
)
const probe = await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-lc', loginProbe]))
const probeText = probe.stdout.toString('utf8')
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
const gitPath = /__ORCA_GIT__(.*)/.exec(probeText)?.[1]?.trim()
@@ -170,10 +164,7 @@ async function main() {
].join(' ')
const delay = options.loginDelayMs > 0 ? `sleep ${options.loginDelayMs / 1_000}; ` : ''
const script = `${delay}${buildWslLoginShellCommand(command)}`
result = await run(
'wsl.exe',
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
)
result = await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-lc', script]))
const markerOffset = result.stdout.indexOf(outputMarker)
if (markerOffset === -1) {
throw new Error('Login shell did not emit the Git output marker')
+3
View File
@@ -13,6 +13,7 @@
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../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-powershell-hook-launcher.ts",
"../src/main/amp/agent-status-plugin-source.ts",
@@ -39,6 +40,7 @@
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
"../src/main/codex/codex-path-observation.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
@@ -54,6 +56,7 @@
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-settings-promotion-write-target.ts",
"../src/main/codex/config-sync-stall.ts",
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
"../src/main/codex/config-toml-key-path.ts",
+28
View File
@@ -0,0 +1,28 @@
{
// NOT ENFORCED YET, and deliberately so: tests/ has never been typechecked, so this currently
// reports ~198 errors across ~94 files. Wiring it into `typecheck` or CI today would just paint
// the whole build red. It earns its place anyway — pointing it at the SSH reconnect spec found
// two real bugs a passing suite had hidden (a targetId read off the wrong type, and a required
// argument omitted), because that spec had never actually executed. Run `pnpm typecheck:e2e`,
// fix a file, repeat; when the count reaches zero, add it to the `typecheck` chain.
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
// Why: specs import renderer and main modules directly, so the ambient declarations those
// modules rely on (vite client types, build-time defines) have to be in the program too.
"include": [
"../tests/**/*.ts",
"../tests/**/*.tsx",
"../src/renderer/src/env.d.ts",
"../src/types/**/*"
],
"exclude": ["../tests/e2e/.cross-version-checkouts/**"],
"compilerOptions": {
// Why: specs run browser code inside page.evaluate, so the DOM lib has to be visible here
// even though the harness itself is Node.
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"paths": {
"@renderer/*": ["../src/renderer/src/*"],
"@/*": ["../src/renderer/src/*"]
}
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 21m">
<title>downloads: 21m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 22m">
<title>downloads: 22m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">21m</text>
<text x="90" y="14">21m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">22m</text>
<text x="90" y="14">22m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -99,3 +99,42 @@ The harness covers the terminal stream only. It does **not** cover the session-t
sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or
the relay transport. A change on those paths still needs its own reasoning against
the three rules above.
## Worked example: `agentWait` on terminal and worker reads
`terminal.show`, `orchestration.workerShow` and `orchestration.federationShow` carry an
optional `agentWait` naming a pane parked on a prompt only a human can answer. It is Rule 1 —
a new optional field — but it has a second state that Rule 1 alone does not describe, and
getting that wrong turns a skew into a false "nothing is blocked".
- **present object** — this pane is waiting, with the evidence that proved it.
- **present `null`** — the host evaluated this pane and nothing proves a wait.
- **absent** — the host never evaluated it: it predates the field, the worker identity was
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
A new client against an old host sees the field absent, which is why absence must read as
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
from a healthy idle worker, which is the exact failure the field exists to remove.
An old client against a new host ignores the key, as Rule 1 allows. New members added to
`RuntimeTerminalWaitBlockedReason` are also Rule 1: no consumer switches exhaustively on it,
and both the CLI and worker-start interpolate it as an opaque string.
## Known debt: JSON-RPC errors drop Node's string code
An error raised on an SSH host crosses the relay as JSON-RPC, and
`ssh-channel-multiplexer` rebuilds it with the TRANSPORT's numeric `code`. Node's
string code — `'ENOENT'`, `'EACCES'` — does not survive, so a caller on this side
cannot ask what kind of failure it was.
`isENOENT` in `src/main/ipc/filesystem-path-containment.ts` pays for that by also
matching Node's canonical message text, which is what makes remote worktree creation
work. The cost is that a host can make an unrelated failure read as "absent" by
putting that sentence in a message.
The exit is Rule 1: carry the original string code in a new optional field on the
error payload and read that instead. An old host omits it and the message match still
covers them; once hosts that send it are the floor, the message match can be deleted
rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code`
without doing this reinstates the bug — the transport has already overwritten it.
+423
View File
@@ -0,0 +1,423 @@
# SSH host key verification (STA-4319)
Revised after security and migration review. Where a first draft was wrong, the correction is kept
visible rather than quietly edited out — the reasoning matters for anyone changing this later.
## The defect
`src/main/ssh/ssh-connection.ts:1184` installs a `hostVerifier` that records a SHA-256 fingerprint
and then `return true`. Every ssh2 connection accepts every host key. There is no `known_hosts`
consult, no trust record, and no change detection anywhere in `src/main/ssh/`. There is exactly one
ssh2 `Client` construction site, so the fix has a single chokepoint.
Scope is per-connection, not per-feature: one `SshConnection` per target serves exec, SFTP, port
forwarding, the filesystem watcher and relay deploy.
### Threat model, corrected
Traffic is still encrypted, so a passive observer gets nothing. The exposure is an **active**
attacker who can redirect the connection — ARP/DNS spoofing, hostile Wi-Fi, a hijacked internal name.
Three corrections to the first draft:
- **Jump hosts are NOT the worst case; they are already safe.** `shouldUseSystemSshTransport`
(`ssh-transport-selection.ts:71-91`) returns true for exactly the conditions under which
`resolveEffectiveProxy` (`ssh-proxy-command.ts:17-38`) returns a proxy — the two branch on the same
inputs in the same order — and `attemptConnect` returns unconditionally after the system probe
(`ssh-connection.ts:670-673`). So ProxyJump/ProxyCommand go through OpenSSH and are already
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
the first draft's motivating example was simply wrong.
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
into `ForwardAgent`, not everyone.
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
owns their machine. The real client-side impact is the **return** direction: the attacker becomes
the host our workspace trusts, driving relay protocol frames, landing SFTP content in local
worktrees, and feeding agent-hook payloads in.
## Decisions
### D1. Read the user's `known_hosts`; write only to our own store
Consult the user's real `known_hosts` as a trust source — most developers already have their hosts
there from `ssh` and `git`, which is the entire migration story. Do **not** write to it: that file is
shared with every other SSH tool on the machine, and appending brings line-endings, permissions,
concurrent writers, and a corruption blast radius well beyond us.
Two consequences to own rather than discover:
- **Revocation does not propagate into our store.** `ssh-keygen -R host` clears `known_hosts` but not
our record. That is survivable for the ordinary rotation, because a `known_hosts` MATCH is now
decided before our store's mismatch — running the remedy we print and reconnecting works, which it
did not when the store was consulted first. What it does not cure is a host we only ever knew
ourselves, never written to `known_hosts`: there is no `ssh-keygen -R` for that one, so the
rejection names the store file directly. The "forget" action (D5) replaces that with a button; its
helper is deliberately absent until then, since an exported API nothing can reach is unverified in
production. Mismatch messaging must keep naming which source disagreed.
- **`ssh -G` on the HOME-divergent `-F` path suppresses `/etc/ssh/ssh_config`**
(`ssh-g-config-resolution.ts:44-52`), hiding site-wide `StrictHostKeyChecking yes` and
`GlobalKnownHostsFile`. On that path we must fail **strict**, never laxer than `ssh` would.
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
`su` shell and Nix shell.
### D2. Ask `ssh -G`, do not reimplement config resolution
`ssh -G` reports `userknownhostsfile`, `globalknownhostsfile`, `stricthostkeychecking`,
`checkhostip`, `hostkeyalgorithms`, `fingerprinthash`, `hashknownhosts`, `updatehostkeys` and
**`hostkeyalias`** — with `Match` and `Include` already applied. `resolveWithSshG` exists and simply
does not read them yet.
`userknownhostsfile` is a space-separated list on one line, may contain `~`, and may contain
double-quoted paths with spaces. When `ssh -G` is unavailable (no `ssh`, non-zero exit, >5s timeout)
fall back to `~/.ssh/known_hosts` + `known_hosts2` — never to accept.
`HostKeyAlias` must be honoured: users tunnelling bastions through `localhost:port` depend on it and
would otherwise hit spurious mismatches. It appears nowhere in `src/main/ssh/` today.
**Lookup key.** Config resolution uses `configHost || label` (`ssh-connection.ts:660`) while ssh2
dials `effectiveHost` (`ssh-connection-utils.ts:188`). The `known_hosts` lookup must use
`HostKeyAlias` if set, else the **resolved hostname** — keying on the Orca label would miss every
existing entry.
**Two ordered lookup passes, not one candidate set.** Verified against OpenSSH 10.2p1: a non-default
port looks up `[host]:port` first, and if that finds nothing it retries the **bare** host. Crucially,
on that second pass a wrong key is downgraded to `unknown` rather than reported as changed. So the
passes are `[['[host]:port'], ['host']]`, and the fallback pass can only yield `match` or `unknown`.
Collapsing them into one set would give a spurious first-contact prompt to anyone who has a bare
line and connects on a non-default port; treating the fallback as authoritative would raise a false
change-of-key alarm.
**The entry condition to that second pass is the part that bites.** ssh runs it only when the
port-qualified lookup matched no plain entry of ANY key type — not "no match". Gating it on
"no match and no same-type mismatch" reaches the bare line when an off-port entry of another type
exists, and returns `match` where ssh prints `IDENTIFICATION HAS CHANGED`: an accept-a-changed-key
path, reproduced live. And the observations from each pass must not leak into the other, or an entry
found only on the fallback refuses a host ssh accepts as first contact.
**`HostKeyAlias` suppresses the port entirely.** ssh looks the alias up bare and never brackets it,
so an alias gets ONE pass regardless of port. Combined with the rule above, a stale `[alias]:port`
line would otherwise block the bare lookup ssh actually performs — turning the bastion case this
feature cites `HostKeyAlias` for into a hard failure.
**Hashed entries hash the candidate form, not the bare host** — `[example.com]:2222` is what gets
HMAC'd for a bracketed entry, so each candidate must be hashed separately.
**Multiple files union.** Any exact hit in any file wins; a disagreeing entry in another file does
not make it a mismatch. Confirmed live in both orderings.
**A `@cert-authority` line whose key equals the presented plain host key is not a match** — a CA line
only validates certificates. A normal line alongside it still decides. But ssh's verdict for a
CA-covered host presenting a plain key is `HOST_NEW`, not a failure: it connects. See D4.
### D3. Six outcomes, and type scoping is only safe with algorithm ordering
`match | mismatch | revoked | ca-only | unknown-type-known-host | unknown`.
Mismatch is scoped to the same key type: a host with only an RSA entry that presents ed25519 is not
"changed". Without scoping we would false-alarm nearly every RSA-era user on their first upgraded
connect, training them to dismiss the one warning that matters.
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
friendly first-contact prompt instead of a hard failure.
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
host.** Type scoping without algorithm ordering is not a safe design.
And when the presented type is unknown *while other types are known for this host*, that is
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
for this host.
### D4. Outcomes
- **match** → connect silently.
- **unknown** → trust-on-first-use (see the phasing below for whether that is silent or prompted).
- **mismatch** → hard fail, no override in the failure surface.
- **revoked** → hard fail, always.
- **ca-only** → ~~hard fail~~ **REVERSED: treated as first contact.** See below.
- **unknown-type-known-host** → treat as suspicious, not first contact.
`StrictHostKeyChecking` is honoured: `no`/`off` accepts unknown but **never persists** and still
hard-fails changed and revoked; `accept-new` persists silently; `yes` denies unknown.
> **`ssh -G` does not report the spelling the user wrote.** StrictHostKeyChecking is rendered through
> `fmt_multistate_int`, which prints the first entry of `multistate_strict_hostkey`, and that table
> lists true/false before yes/no. So `yes` arrives as `true`, `no` and `off` both as `false`; only
> `ask` and `accept-new` pass through unchanged. Matching on `yes`/`no`/`off` matches nothing a real
> config can produce. `UpdateHostKeys` has the same shape (`true`, not `yes`).
**ca-only, reversed after review.** The rejection was stricter than ssh, and the blast radius was
mispriced. An SSH CA user holds ONE line — very often `@cert-authority *` — which matches every
candidate, so EVERY target failed, not just CA-signed ones, including on-demand runtime VMs, and
`StrictHostKeyChecking=no` did not help. `ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1` is read from the
process environment, which an Electron app launched from the Dock or Start Menu does not have, so
the documented escape was unreachable for exactly the people who needed it. And OpenSSH's own
verdict for a CA-covered host presenting a plain key is `HOST_NEW`: it connects. ssh2 cannot
validate certificates at all, so refusing conceded nothing ssh was not already conceding.
The residual risk is accepted, not resolved: for a CA-protected host we take a plain key we cannot
tie to the CA. Certificate support is Phase 2 work. The `ca-only` outcome is still produced and
carried through the decision so the log shows a CA line was involved.
**An unreadable known_hosts connects but records nothing.** A file that EXISTS and will not open is
the absence of evidence, and the common trigger is not exotic — a Windows OneDrive Known Folder Move
placeholder while offline fails with a cloud-file error, not ENOENT. Refusing there broke an
ordinary corporate laptop while blaming a config file that was fine, and was asymmetric with our own
store, which degrades to "nothing trusted" and connects. ssh warns and treats the host as unknown;
so do we — but we write no record, so a first contact we could not check never becomes durable
trust. An ABSENT file is not this case: that is the normal state for a fresh profile and genuinely
means nothing is known.
### D5. Recovery must not live in the failure dialog
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
shows the stored fingerprint so the user is choosing knowingly.
Offer it only when **our** store is what disagreed; when `known_hosts` disagrees, forgetting our
record cannot unblock the connect. Messages, written to avoid naming internals:
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
> no button, because a button would not help.
### D6. Never prompt on a background reconnect
A prompt only means something when a human initiated the connect. `userInitiated` does not exist on
the connect path today and must be threaded through `connect → attemptConnect → doSsh2Connect`,
defaulting **false**.
Two traps: `useAutomationDispatchEvents.ts:203` and `pty-connection.ts:857` reach `ssh:connect`
without a human click — automation must pass `false`, but **terminal-pane focus reconnects must count
as user-initiated** or terminals die silently. And the denial string must avoid "authentication
failed"/"permission denied", or `isAgentFallbackError`/`isAuthError`
(`ssh-connection-utils.ts:46-61`) misclassifies it and the reconnect ladder retries a decision that
will never change.
### D7. Fail closed — three known fail-open shapes
1. The existing generation/disposed guard at `:1185` has the fail-open shape today: skip recording,
still `return true`. Post-fix that branch must **deny**.
2. A synchronous throw inside the verifier may not be caught by ssh2 — wrap and `verify(false)`.
3. Any non-`undefined` return accepts immediately (see Traps).
Plus: no prompt channel registered → deny (the load-bearing default lives in `doSsh2Connect`, not in
IPC, so a caller that forgets to wire it cannot accidentally accept); no window → deny; timeout →
deny; dialog dismissed → deny.
### D8. Store shape and scope
Accepted keys are scoped to **host + port + key type**, not target id — aliases point at different
machines, two targets can name one machine, and a re-created target must not lose trust.
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
the system transport, so **no trust logic may key off it**, and its format must not change (see
Traps).
## Phasing — ship the defence before the dialog
Review made the case that the riskiest part of this change is not the security model but the modal.
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
dialog would open on someone else's screen while the web user watches a spinner.
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
silently with `accept-new` semantics and a passive notification naming the host and fingerprint.
`mismatch` (same type) and `revoked` hard-fail. This is the entire MITM defence with zero prompts,
zero startup storms and zero web hang.
**Phase 2** — the TOFU dialog, `StrictHostKeyChecking` honouring, `ca-only`, `userInitiated`
plumbing, and the D5 settings surface.
Carve-outs required before Phase 1 ships:
- **Runtime-owned ephemeral targets are exempt from persistence** — a new key every launch is
expected, not suspicious, and recording one would accumulate a row per launch that eventually
reads as a spurious change. Implemented via `target.owner?.type === 'on-demand-runtime'`.
- **RPC-originated connects: NOT needed in Phase 1, required in Phase 2.** The review asked for
these to fail fast rather than leave a paired-web user watching a spinner for the 120s prompt
timeout. That hang is only reachable if a prompt exists, and Phase 1 has none — the decision
function is pinned by a test asserting it never returns `prompt`. An RPC connect therefore behaves
exactly like a local one: it accepts and records on first contact, or fails immediately with the
host-key reason. Adding a fail-fast path now would introduce a failure mode for a hang that cannot
occur. It becomes load-bearing the moment the dialog lands, and is listed in Phase 2.
Worth noting for Phase 2: `runtime/rpc/methods/ssh.ts` already swallows the specific error and
rethrows `getPublicSshError(status)`, so a web client sees a generic failure rather than the
host-key reason. Pre-existing, but it means the Phase 2 message will not reach the web user
without a change there too.
## Traps
Each of these makes the fix silently do nothing. All confirmed in our tree.
1. **An `async` verifier defeats it entirely.** ssh2 does
`const ret = hashCb(key, verify); if (ret !== undefined) verify(ret)`. An async function returns a
Promise — not `undefined`, and truthy — so ssh2 accepts before our callback settles.
2. **Do not set ssh2's `hostHash`.** It hands the callback a hex digest and discards the raw blob we
must compare — and it would change `hostKeyFingerprint`'s format, which is a cross-version state
break, not a local refactor.
3. **The existing test mock calls `hostVerifier(key)` with one argument** and ignores the return
(`ssh-connection.test.ts:86-91`). Under an async verifier every connect test there breaks. The
mock must change — flagged deliberately, not rewritten silently.
4. **Validate the blob**: embedded algorithm name must match the line's key-type field; reject empty
decodes, empty salts, and hashed entries whose hash is not 20 bytes.
5. **`ssh-relay-live-connect.test.ts:59`** constructs a connection with no credential callback —
headless with no prompt channel must deny, not hang.
## Scope
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
`unknown`, which is the prompt-training harm D3 exists to avoid.
**Out of scope, with consequences stated:**
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
users get first-contact treatment for hosts they already verified.
- **`UpdateHostKeys`** — we read it and use nothing, so we never learn a rotated key, which makes D5
the routine path for key rotation rather than an exception.
- **Moving SFTP to the system transport** — correct direction, separate change.
## Test plan
**Parser** (against the file format, not our code's shape): plain lines, `host,host2` lists,
`[host]:port` used only when port ≠ 22, IPv6 literals, hashed `|1|salt|hash` with a real computable
vector, `@revoked`, `@cert-authority`, `*`/`?` globs, `!` negation vetoing a whole line, unrecognised
`@marker` skipping the line, malformed lines skipped not fatal, multiple keys per host, CRLF, blank
lines, comments, user file and global file disagreeing.
**Decision function**: all six outcomes; type scoping; revocation resolved before match regardless of
line order; every `StrictHostKeyChecking` value; `no`/`off` never persists.
**Algorithm ordering**: `algorithms.serverHostKey` leads with types on file — the test that makes D3
safe rather than merely scoped.
**Wiring**: unknown persists (Phase 1) without a prompt; match never notifies; mismatch fails with no
accept path; revoked fails; background reconnect denies; aborted connect settles pending verify
false; no prompt channel denies; runtime-owned targets are exempt; the denial string does not match
`isAuthError`; and — catching the worst regression — **the verifier returns nothing**, so a refactor
to `async` reddens a test rather than reaching a user.
**Checked against a live client, not just the file format.** Two assumptions the design leans on were
verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1:2222` and recording
its verdict:
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
choice.
- **`unknown-type-known-host` is ssh's own behaviour.** known_hosts holding `ssh-rsa` while the
server offers ed25519 makes ssh print `IDENTIFICATION HAS CHANGED` and refuse. So the rejection is
neither stricter nor laxer than ssh — and treating it as first contact, which a naive type-scoped
lookup does, is the laxer mistake. It also means `ssh-keygen -R` is the right remedy to name there.
## What Phase 1 shipped, and what review changed
The design above survived implementation. Every defect found afterwards was in the wiring, and the
pattern is worth recording because it repeats: **each one made us either blind or unusable, never
subtly wrong.**
Fixed after review:
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
ordering — without that the guard is only half present.
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
forbids. We consulted no entries at all.
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
Rejections are now a typed error recognised before any fallback.
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
same as an unreadable one, so a profile that had never connected — everyone's first run — would
have been refused, and the suite passed only because dev machines have a `known_hosts`.
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
incomplete-sources check, so a HOME-divergent environment turned on-demand runtimes off entirely.
A second review round, run against a live OpenSSH client and sshd rather than against the source,
found five more — and the pattern held: the two that mattered most were both cases where we refused
a host `ssh` connects to, and the worst single defect was that **`StrictHostKeyChecking` had never
been read correctly at all**, so a config saying `yes` was silently accepted AND persisted. See the
D2/D3/D4 corrections above. The lesson worth keeping: every one of these was invisible to unit tests
that fed the code the value a human writes, rather than the value the tool emits.
## Action items (STA-4319)
**Where the message actually lands.** Traced end to end, because a rejection the user cannot read is
a half-shipped feature. Fixed in this branch: the settings card clamped it to one line with no
tooltip, and the terminal reconnect overlay never asked for it at all. Still open:
- **The "Remote Hosts" status bar shows only `Error`.** `SshTargetStatusRow` does not receive the
error, so the status bar is a dead end for the most likely place a user notices the failure.
- **"Connect again" is the wrong advice for a decision that will never change.** The terminal overlay
now prints the reason underneath, but its call to action still invites an action that cannot
succeed. Telling a permanent rejection from a transient fault in the renderer needs a typed reason
on the wire rather than a string — a remote-wire-compatibility decision, so deliberately deferred.
- **Toasts carry Electron's `Error invoking remote method 'ssh:connect':` prefix.** The repo has
strippers for exactly this; no SSH call site uses one. Also worth noting sonner auto-dismisses in
4s, which is short for a message ending in a command the user is meant to copy.
**Before Phase 2:**
- **`UpdateHostKeys` (out of scope above, now the highest-value gap).** We read it and use nothing,
so a rotated key is a hard failure the user must resolve by hand. Combined with D5 this is the
routine path for key rotation, and it will be the most common way a legitimate user meets a
rejection. Decide whether Phase 2 honours it or D5's recovery surface absorbs it.
- **The web user never sees the reason.** `runtime/rpc/methods/ssh.ts` rethrows
`getPublicSshError(status)` on all three paths, and push events are redacted through
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
- **WSL** — a distro's `known_hosts` is unreachable, so WSL users get first-contact treatment for
hosts they already verified through `ssh` inside the distro.
- **`CheckHostIP`** — candidates are formed from the hostname only.
- **Certificate validation** is still absent — a CA-covered host is now accepted on first contact
rather than refused (D4), so those users connect, but the CA itself verifies nothing for us.
- **`DEFAULT_SERVER_HOST_KEY_ALGORITHMS`** is a hand-copy of an ssh2 internal. A test pins it, so an
ssh2 upgrade that changes it fails CI rather than shipping — but the pin has to be honoured, not
deleted, because ssh2 throws `Unsupported algorithm` and every target stops connecting.
**Rollout:** the first release carrying this is the first time Orca can refuse an SSH connection at
all. Worth a staged rollout or a kill switch: the failure modes we could not find are, by the shape
of the five above, far more likely to be "a legitimate host is refused" than "a bad key is accepted".
@@ -0,0 +1,147 @@
# SSH reconnect: why the pane retry gets a byte tail, and what would actually change it
Status: investigation result. The obvious follow-up to PR #14844 was traced and **rejected**, and
tracing it turned up the actual root cause: checkpointed source recovery has never run on an SSH
reconnect. Both are recorded here — the rejected shape so nobody re-proposes it, and the verified
cause with the fix it implies.
## The shape of the problem
A reconnect remounts the pane (`tab.generation` is its React key), so the xterm is disposed with its
buffer and something must repaint it. Today that is a **byte tail**: `reattachSshPtySession` sends
`requireReplay: true` and the relay returns `RecentPtyOutputBuffer.read()` — the last 100KB, read
non-destructively, with no notion of what this client already consumed.
Two costs follow. Main's `@xterm/headless` model never sees those bytes (the tail bypasses
`onPtyData`), so it is stale by exactly the outage — which is what forces
`sshReconnectPaintsFromModel` to restrict the grid repaint to the alternate screen. And a shell loses
outage output past 100KB permanently.
## The proposal that does not work
"Make the pane-retry path request source recovery like `reattachKnownPtys` does." Mechanically this
is trivial — `sourceRecovery` is already an optional `pty.attach` param the relay parses, Path C
already calls the same `requestSshPtyAttach` helper and already parses the response field. The
required checkpoint state also survives a transport drop, in the module-level `recoveryByTarget` map
(`ssh-pty-consumer-recovery.ts:17`), reachable from `connectionId` because `connectionId === targetId`.
It still fails, three ways:
1. **The relay answers `'existing'` before it looks at the recovery argument.**
`relay-pty-source-publication.ts:99-109` short-circuits on a same-`clientId` attach, and a
reconnected client presents the same id — see the root-cause section below, where this turns out
to be the whole story rather than an obstacle specific to this proposal.
2. **A failed `reattachKnownPtys` deletes the checkpoint on purpose** (`ssh-relay-session.ts:3006-3007`)
and detaches the lease (`:3008`). The pane retry runs _after_ that, so it would present
`checkpointUnavailable`, which the relay converts to `restoreRequired`
(`relay-pty-source-publication.ts:124-130`) and the provider converts to
`SSH_SESSION_EXPIRED_ERROR` (`ssh-pty-provider.ts:103-107`). We would trade a blank-pane-with-tail
for a **killed session**.
3. **Wrong payload shape.** Recovery replays only the post-checkpoint delta
`(acceptedSourceEndSu → receivedEndSu]`. The byte tail is a screen snapshot for a _fresh, empty_
xterm. Even a successful recovery returns roughly nothing in the common case, and the pane stays
blank.
These two mechanisms answer different questions. Recovery keeps main's model whole; the tail repaints
a new terminal. Substituting one for the other is a category error.
## A correction worth recording
The motivating argument was "`requireReplay` is optional, so older relays ignore it and still show
blank panes." **That is wrong for the SSH relay.** The client deploys and launches its own relay
build into a version-scoped directory (`ssh-relay-deploy.ts:231`, `:594`), and `validateGrant`
rejects any grant whose `serverBuildId` differs from the expected one
(`ssh-pty-consumer-session.ts:58-65`, rationale in-code: _"client and relay ship in one build"_).
Client and SSH relay are version-locked; mixed versions do not occur on this channel. The
independent-update rule in `remote-wire-compatibility.md` still governs remote _runtime_ hosts — just
not this one.
So there is no old-host population to rescue, and the urgency that argument created was false.
## ANSWERED: checkpointed recovery never runs on an SSH reconnect
The question above was "does the reconnecting client present a new `clientId`?" It does not, and the
consequence is that the whole checkpoint mechanism is dead on this path. Every link verified:
1. **The client keeps its id.** A reconnect calls `Dispatcher.setWrite`
(`src/relay/dispatcher.ts:149-157`), which reuses `this.primaryClient` — including its `id` — and
replaces only the writer. The dispatcher refuses to detach the primary. This is already stated
in-repo at `src/relay/pty-handler.ts:1736-1742`.
2. **So `activate()` short-circuits.** `relay-pty-source-publication.ts:99` tests
`current?.clientId === context.clientId` and returns `'existing'` at `:108`. The `rotateDelivery`
recovery path at `:118-142` is reachable **only** when the ids differ — i.e. never, here.
3. **So the relay returns no `sourceRecovery`.**
4. **So the client abandons.** `finishSourceRecovery` (`ssh-relay-session.ts:2766-2785`) fails its
`!pendingRecovery` guard, calls `abandonPtySourceRecovery`, and returns false — which cancels the
delivery and deletes the checkpoint (`:3006-3008`).
5. **So the pane retry opens fresh and gets the byte tail**, via the `requireReplay` fix.
The byte tail is therefore not a fallback. It is the only path that has ever run for an SSH
reconnect, and the flow-control/checkpoint machinery is inert on this path.
That also explains the original blank-pane bug exactly: the relay concluded "this client already
holds the stream" because, by its own identity rule, it does.
### The fix this implies
Give a reconnected primary a distinguishable identity — a transport generation on the client record,
bumped in `setWrite` — and have `activate()` compare it alongside `clientId`, so a reconnect takes
`rotateDelivery` instead of `'existing'`.
Why this is the tractable shape:
- **No wire change.** `RequestContext`, `setWrite` and the publication are all relay-internal.
- **No compatibility exposure.** Client and relay ship in one build and are version-locked.
- **It does not disturb the invariant that broke three earlier attempts.** Deliveries still outlive
their clients; nothing retires on `onClientDetached`. The delivery is _rotated on re-attach_,
which is what the recovery design already intends and what its tests already cover.
**UNVERIFIED and to be checked before implementing:** that `rotateDelivery`'s preconditions hold at
that moment (the checkpoint's `deliveryToken`, `clientGeneration`, `ownerGeneration` and
`ptyIncarnation` must match the live identity, `:124-128`); that `outputFlowControl` is granted on
the reconnected session; and what a rotation implies for the _renderer_, which still remounts with an
empty xterm and needs a screen, not a post-checkpoint delta. Recovery keeps main's model whole — it
does not by itself repaint a fresh terminal, so the tail may still be wanted for the pane even once
the model stops going stale.
## Do not start at `onClientDetached`
Three attempts failed there, each plausible until run:
- Retiring the delivery on `dispatcher.onClientDetached` **breaks checkpoint recovery** (10 tests).
A delivery outliving its client is deliberate — it is what lets a client resume from a checkpoint.
- Retiring without `session.cancelDelivery()` orphans the credit ledger's one-upstream-owner slot;
the next open throws `PTY source delivery already has an upstream owner`. Seen live as a toast and
a blank pane.
- Comparing `record.identity.clientGeneration` to the request is impossible: that value is
client-supplied via `pty.openClient`, and `RequestContext` carries no generation of its own.
## The lead that survives
`reattachRejectedPty` (`ssh-relay-session.ts:1957-2004`) is an existing **single-PTY** entry point
into the `reattachKnownPtys` machinery, taking `(relayPtyId, mux, providerGeneration, mode)` and
driving recovery with `targetedDeliveryRecovery`. If per-pane recovery is wanted, that is the hook —
and it does not involve the pane-retry path at all. Unverified whether it is reachable at the moment
the renderer retries.
## Preconditions, unchanged
The SSH e2e lane must be green and triggering on **source** changes before any of this is attempted.
It was skipping for 15 specs; four regressions reached a user during that window.
## Open: the pane behind a preserved tab does not always rebind
The merge now keeps a local tab the host has never been told about, so the tab and its title survive
a reconnect. The reattach behind it does not, reliably — measured at three runs in four against the
Docker-SSH lane. When it misses, the store holds the tab, the tab bar renders it, and the pane never
rebinds: the "frozen tab" shape the original report described, one layer down from the deletion that
used to cause it.
Deliberately NOT asserted in `ssh-reconnect-tab-destruction.spec.ts`. A one-in-four flake in the lane
that exists to catch this class costs more than it proves — the lane stops being trusted, which is
exactly how the earlier silent-skip failure happened. Tab survival is asserted there and is
deterministic; the liveness gap is recorded here instead.
Worth checking first, since it is the same shape as everything else in this file: the tab is absent
from the host snapshot, so whatever drives the per-tab reattach after an apply may simply not know to
reattach a tab the snapshot never mentioned.
+72
View File
@@ -0,0 +1,72 @@
# Running commands inside WSL
Two properties of `wsl.exe` decide how every guest invocation has to be written. Both are silent
when you get them wrong: the command still runs and still exits 0, it just returns the wrong bytes.
## 1. Always `--exec`, never `--`
`wsl.exe -d <distro> -- <argv>` expands `$name` in **every argument** against the guest environment
before the guest runs. This is `wsl.exe` itself, not the guest shell — it happens with no shell in
the command at all:
```
$ wsl.exe -d Ubuntu-24.04 -- /usr/bin/printf %s '$HOME'
/home/you
$ wsl.exe -d Ubuntu-24.04 --exec /usr/bin/printf %s '$HOME'
$HOME
```
So under `--`, a script means something other than what it says. `awk '{print $2}'` reaches the
guest as `awk '{print }'` and prints the whole line; a positional `"$1"`, a shell local, and a
`"\$literal"` are blanked or rewritten the same way. (Expansions with no `$` are unaffected — a
`sed` backreference like `s/(a)(b)/\2\1/` survives either way.) Escaping `$` on the Windows side
cannot fix this reliably — an earlier attempt skipped every `$` preceded by a backslash, which is
exactly the case a POSIX script uses to mean a literal dollar.
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
tree and fails if the `--` form reappears.
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
## 2. Machine-read output must be fenced
Orca runs guest commands through the distro user's **interactive** login shell (`-ilc` for
bash/zsh) because that is the only shell that reads `~/.bashrc`, where `nvm`, `mise` and `asdf`
install their PATH entries. Dropping `-i` would break tool detection for those users.
The cost is that an interactive shell also runs the distro's rc/motd, and that output goes to
**stdout** — the same stream the answer arrives on. Stock Ubuntu 24.04 needs no customization to
reproduce it:
```
$ wsl.exe -d Ubuntu-24.04 --exec bash -ilc 'git --version'
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
git version 2.43.0
```
Any caller that parses stdout must use `buildWslCapturedLoginShellCommand()`, which fences the
payload and returns a matching `readStdout`. `.trim()` does not help: the banner is a prefix, not
surrounding whitespace, so a stat probe compared against `"directory"` simply never matches.
The fence carries a per-call nonce so that `cat`-ing a file whose contents happen to quote a marker
is not truncated, and it preserves the payload's exit status so `exit 2` → `ENOENT` mappings keep
working.
**Do not fence a command that `exec`s into a long-running program** (`codex app-server`, an
interactive terminal). It never reaches the closing fence, and there the shell's own output either
belongs to the program or is what the user wants to see.
## Prefer no shell at all
When a caller only needs a known binary with a known environment, skip the login shell entirely and
run the binary directly:
```
wsl.exe -d <distro> --exec /usr/bin/env PATH=… HOME=… /usr/bin/git -C <dir> status
```
This is what the direct-git read path does. It is immune to both problems above by construction and
avoids paying login-shell startup on every call, which also sidesteps profiles that block or print.
Resolve the PATH/HOME once through a fenced probe, cache it per distro, then use this form.
+244
View File
@@ -0,0 +1,244 @@
# xterm Patch Regeneration
## Scope
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
not taken: the IME composition hooks, the `xterm-composition-*` custom events
they raise, the `ICompositionHelper` surface those hooks widen, and a `SortedList`
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
That patch touches eight files. Four are hand-authored source
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
generated, and this document exists so nobody edits it by hand.
The two halves are the same edits diffed two ways, so the generator requires
them to match byte for byte on every source file. A hunk the shipped patch
cannot name — upstream's `.npmignore` strips `src/**/*.test.ts` — would be
dropped by the next `--write`, so it fails the run instead.
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
3. Every source change must land together with the regenerated bundle hunks and
the `pnpm-lock.yaml` hash bump, in one commit.
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
comment. A version bump that leaves it stale fails the generator, it does not
silently patch the wrong tree.
5. Sourcemaps move with the bundle, and are never silently omitted. The patch
moves the code, so dropping only the map hunks would ship offsets pointing at
the wrong lines. `sourcemaps.policy` accepts `include` and nothing else: it
costs about 5.8 MB of the emitted patch and is required because
`src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts`
reads `lib/*.map` and asserts the mapped `Version.ts` matches the runtime
version. Deleting the maps was once an option; the code that did it was
removed as unreachable, so re-adding the policy means re-adding that code.
6. `--check` is the authority on the lockfile, not `pnpm install`. pnpm writes the
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
# 3. Reinstall so node_modules picks up the new patch hash.
pnpm install
# 4. Confirm the tree is self-consistent.
node config/scripts/regenerate-xterm-patches.mjs --check
```
Editing a patch file by hand is awkward for anything larger than a one-liner.
For a substantial change, work in the generator's own checkout instead — after
any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
Run the checkout outside this repository. A build tree underneath it makes
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
identifier`, which is a symptom of where the tree sits and not of the patch.
## How the Commit Is Known
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
each published tarball names the commit that built it. The generator asserts
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
against the checkout file by file. Only `src/common/Version.ts` may differ,
because `publish.js` rewrites the version immediately before packaging; the
generator applies the same stamp.
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
commit would still produce a plausible-looking 7 MB patch.
## Build Order
Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`.
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
50% larger than the published one, which is easy to miss inside a 7 MB diff.
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
The generator also builds the *unmodified* commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
## Recovering From Hand-Edited Bundles
Between 2026-08-09 and 2026-08-17 this harness did not exist, and four fixes
landed by editing the minified bundles directly. The tell is code no minifier
emits: `const` in an otherwise `let`-only bundle, and identifiers like `$rl`,
`$hp`, `$tid`.
Recovery is not a rewrite. The hand-edits were applied to `src/` as well, so the
source hunks in the shipped patch were already correct and `--write` re-derives
the bundles from them. What changes is cosmetic and expected:
- Hand-written locals collapse back into minifier names, which shifts esbuild's
frequency-ordered allocation and can swap two short names bundle-wide (`i`↔`t`
in the `.mjs`, `w`↔`y` in the `.js`). Most differing lines are the same length.
- Hand-written equivalents normalize to what the toolchain actually emits
(`!!x` back to `Boolean(x)`, an escaped `\u200E` back to the literal
character).
To confirm a regeneration is semantically a no-op rather than a revert, compare
identifier multisets between the old and new bundle instead of reading the diff:
every name that is not a single-letter minifier local should appear the same
number of times in both. Anything else is a real change and needs explaining.
## The Lockfile Moves With the Patch
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
the sha256 of the patch file itself. A regenerated patch without the lockfile
bump fails `pnpm install --frozen-lockfile` on every machine except the
author's. `--write` makes that edit; `--check` fails if it is missing.
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
without a network or a build, so the ordinary test job catches lockfile drift
in milliseconds even though the full rebuild runs in its own CI lane.
## Toolchain Pin
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
at the pinned commit, and the generator fails if `npm ci` produces something
else. The entry that matters is `@typescript/native-preview`
(`tsgo`), which upstream pins to a **dated development build** —
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
and npm does not prune old releases, but it is the one dependency of this scheme
that is not a stable release.
If that version ever becomes unresolvable the generator fails with a toolchain
error naming it. Recovery is to move the pin to the next upstream commit whose
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Version Bumps
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
2. Rename both patch files to the new version and update `patch`,
`sourcePatch`, and `version` in `xterm-upstream.json`.
3. Update `upstream.commit` to the `commit` field of the new tarball's
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
rerun. The bundle hunks need no attention at any point.
## Why Not Vendor a Fork
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
the published package, so every upstream beta becomes a merge rather than a
version bump, and Orca inherits responsibility for building and publishing a
package it does not own. The patch is four small source hunks against a commit
that reproduces byte for byte; a fork is a much larger standing cost for the
same result.
## Why Not Handle Composition at Runtime
`CompositionHelper` hooks four private call sites upstream of `onData`, and
`SortedList` has no public surface at all. There is no supported extension point
that reaches either, so a runtime shim would mean reaching into `_core`
internals that upstream renames freely between betas. The patch is the smaller
risk.
## CI Contract
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
twice, and byte-compares the result against the committed patch. Both builds and
the diff together are about eight seconds; `npm ci` for upstream's toolchain is
what the job actually spends its minutes on, and the cache key is the manifest.
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
commit and build-order assertions, and lockfile coupling — with no network and
no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` rules out for `@xterm/xterm`
and which folding them into this manifest would also fix.
Both addons do build from the pinned commit: the registry stamps
`53a98a720ae4a973e384fa2440880d09537132f3` on `addon-webgl@0.20.0-beta.286` and
`addon-serialize@0.15.0-beta.287` alike, despite the mismatched version numbers.
On 2026-08-17 their published `lib/*.mjs` and `lib/*.mjs.map` reproduced byte for
byte from that commit. That was a one-off measurement, not an invariant: no check
in this repo re-runs it, so treat it as a starting point to re-measure rather than
as something the harness holds true.
What blocks folding them in is the other half of their published output. Both
also ship a CJS `lib/addon-*.js`, and the root `package` script does not build
it — upstream's webpack entry is core's `Terminal.js` only, and `postpackage`
emits ESM. So a manifest entry for either addon needs a build step this harness
does not have, and the CJS halves are **unverified**: nothing here has yet
reproduced them from source. Until that exists, folding them in would emit a
patch whose CJS stanza came from the current hand-edited bundle.
+10 -4
View File
@@ -1,6 +1,12 @@
# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile and
# .github/workflows/mobile-build.yml). macOS runners ship a fastlane, but
# pinning here keeps the release toolchain stable across runner image bumps.
# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile,
# .github/workflows/mobile-ios-release.yml, and the Fastfile smoke check in
# .github/workflows/mobile.yml). macOS runners ship a fastlane, but pinning here
# keeps the release toolchain stable across runner image bumps.
#
# Why an exact version plus a committed Gemfile.lock: ios-build (macos) and
# ios-distribute (ubuntu) resolve gems ~25 minutes apart, so an unpinned
# fastlane could split a single release across two versions. Bump deliberately
# in a PR so the Fastfile smoke check runs against the new version first.
source "https://rubygems.org"
gem "fastlane"
gem "fastlane", "2.238.0"
+366
View File
@@ -0,0 +1,366 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1281.0)
aws-sdk-core (3.254.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.229.0)
aws-sdk-core (~> 3, >= 3.254.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.0)
logger
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.238.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.2.5)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.66.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.3)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.2)
jwt (3.2.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
rexml (3.4.4)
rouge (3.28.0)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin
ruby
x86_64-linux
x86_64-linux-gnu
DEPENDENCIES
fastlane (= 2.238.0)
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1281.0) sha256=97fcc7d6a068ab790a1efcd8c6587c08fb108b4bed7ff1409cb47e0d642782ec
aws-sdk-core (3.254.1) sha256=518089e32134c3478cd4ec63d07fb966546a45e9e4cbf5f80d2cf16d5699d29b
aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749
aws-sdk-s3 (1.229.0) sha256=7dd11a111875b3505d2ec0a0a383a6aab6c1badb3d4e94e7fbb958a24451f596
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
excon (1.7.0) sha256=b8deec2bf978123b7e4b13a103ffec61557c6feaba34706dbc1ce4a94dec33e0
faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.106.0) sha256=b90b5312b70f8f731def88f24a2b8fb791fe1b979a7c2c14a905cb6cae19cf3f
google-apis-core (1.2.5) sha256=e21562b5627a0fc6a0c3165e429c3afed0f6a628eaa514e83f7204dda1adff69
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
google-apis-storage_v1 (0.66.0) sha256=fdf6d65d3fc77e7046b3494333a818ece9e2afd763bb161e1a7a9e70cf198351
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330
google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
googleauth (1.17.3) sha256=9a655455885c0c17a8b70537acd2a992a7c7842c43c06c6612dfb0d93e755d58
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.11
+5 -5
View File
@@ -32,7 +32,7 @@ import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
useLastConnectedAt,
usePendingConnectionPath,
useRelayRecoveryStatus,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import {
@@ -139,7 +139,7 @@ export function HostScreen({
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const relayRecovery = useRelayRecoveryStatus(hostId)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
// Why: useRef, not useMemo — React may discard memoized values, which would silently
@@ -820,7 +820,7 @@ export function HostScreen({
state: connState,
reconnectAttempts,
lastConnectedAt,
pendingPath: pendingConnectionPath
...relayRecovery
})
return (
<>
@@ -1099,8 +1099,8 @@ export function HostScreen({
)}
</View>
{/* Auth failed banner */}
{connState === 'auth-failed' && (
{/* Auth failed: a latched relay rejection must reach the same re-pair affordance. */}
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
<AuthFailedBanner
canRetry={!!hostId}
onRetry={() => hostId && void forceReconnectHost(hostId)}
+42 -30
View File
@@ -61,7 +61,7 @@ import {
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useLastConnectedAt,
usePendingConnectionPath,
useRelayRecoveryStatus,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
@@ -208,6 +208,7 @@ import {
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import { resolveActiveSessionTab } from '../../../../src/session/active-session-tab'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
@@ -737,7 +738,7 @@ export default function SessionScreen() {
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const relayRecovery = useRelayRecoveryStatus(hostId)
const forceReconnectHost = useForceReconnect()
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
client,
@@ -933,6 +934,8 @@ export default function SessionScreen() {
const activeHandleRef = useRef<string | null>(null)
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
// Why: survive transient snapshot gaps so the device's own tab pick can re-bind.
const selectedSessionTabIdRef = useRef<string | null>(null)
const pendingActiveTerminalHandleRef = useRef<string | null>(null)
// Why: remember the page id to activate its session tab once it syncs (bridge auto-activate flags only webContents, not the app-level active tab).
const pendingBrowserFocusPageIdRef = useRef<string | null>(null)
@@ -1740,26 +1743,28 @@ export default function SessionScreen() {
const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
let active = snapshotActive
let selectionSource = 'snapshot'
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveSessionTabIdRef.current = null
} else {
selectionSource = 'pending-tab-local-ack'
}
} else {
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
if (pendingTab) {
// Why: desktop tab snapshots can lag a mobile tap mid-activate-RPC; keep the local selection to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
pendingActiveSessionTabIdRef.current = null
}
}
const followsHost = result.navigationIntent === 'follow'
const pendingActiveTerminalHandle = followsHost
? null
: pendingActiveTerminalHandleRef.current
if (followsHost) {
pendingActiveTerminalHandleRef.current = null
pendingBrowserFocusPageIdRef.current = null
}
const resolved = resolveActiveSessionTab(nextTabs, {
pendingActiveSessionTabId,
selectedSessionTabId: selectedSessionTabIdRef.current,
navigationIntent: result.navigationIntent
})
let active = resolved.activeTab
let selectionSource: string = resolved.selectionSource
if (resolved.clearPendingActiveSessionTabId) {
const localAck =
!followsHost &&
snapshotActive?.id === pendingActiveSessionTabId &&
!confirmsMirroredTabSelection(result.publicationEpoch)
selectionSource = localAck ? 'pending-tab-local-ack' : selectionSource
pendingActiveSessionTabIdRef.current = localAck ? pendingActiveSessionTabId : null
}
if (pendingActiveTerminalHandle) {
const pendingTerminalTab = nextTabs.find(
@@ -1769,14 +1774,15 @@ export default function SessionScreen() {
const pendingTerminalExists = mergedTerminalsForActive.some(
(terminal) => terminal.handle === pendingActiveTerminalHandle
)
if (
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle
) {
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveTerminalHandleRef.current = null
} else {
if (active?.type === 'terminal' && active.terminal === pendingActiveTerminalHandle) {
if (
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle &&
!confirmsMirroredTabSelection(result.publicationEpoch)
) {
selectionSource = 'pending-handle-local-ack'
} else {
pendingActiveTerminalHandleRef.current = null
}
} else if (pendingTerminalTab) {
// Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup.
@@ -1799,6 +1805,9 @@ export default function SessionScreen() {
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
if (!resolved.retainSelectedSessionTabId || active !== resolved.activeTab) {
selectedSessionTabIdRef.current = active?.id ?? null
}
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2590,6 +2599,7 @@ export default function SessionScreen() {
activeHandleRef.current = null
activeSessionTabTypeRef.current = null
pendingActiveSessionTabIdRef.current = null
selectedSessionTabIdRef.current = null
pendingActiveTerminalHandleRef.current = null
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
@@ -4068,6 +4078,8 @@ export default function SessionScreen() {
// so comparing against the ref keeps the anchor from being nulled out.
if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) {
activeSessionTabTypeRef.current = null
// Why: an explicit close is not a transient gap; drop the sticky pick so the snapshot picks the next tab.
selectedSessionTabIdRef.current = null
activeSessionTabIdRef.current = null
setActiveSessionTabId(null)
activeHandleRef.current = null
@@ -4174,7 +4186,7 @@ export default function SessionScreen() {
reconnectAttempts,
lastConnectedAt,
endpoint: hostEndpoint,
pendingPath: pendingConnectionPath
...relayRecovery
})
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
+3 -3
View File
@@ -37,7 +37,7 @@ import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import {
useLastConnectedAt,
usePendingConnectionPath,
useRelayRecoveryStatus,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../../../src/transport/connection-health'
@@ -2074,7 +2074,7 @@ export default function MobileTasksScreen() {
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const relayRecovery = useRelayRecoveryStatus(hostId)
const clientRef = useRef<RpcClient | null>(null)
const loadGenerationRef = useRef(0)
const taskResumeRef = useRef<TaskResumeState>({})
@@ -8676,7 +8676,7 @@ export default function MobileTasksScreen() {
state: connState,
reconnectAttempts,
lastConnectedAt,
pendingPath: pendingConnectionPath
...relayRecovery
})
const emptyLabel =
connState !== 'connected'
+9 -1
View File
@@ -263,6 +263,13 @@ export default function HomeScreen() {
() => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])),
[allClients]
)
const hostPairingRejected = useMemo(
() =>
Object.fromEntries(
allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected])
),
[allClients]
)
const disconnectHostClient = useDisconnectHostClient()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
@@ -770,7 +777,8 @@ export default function HomeScreen() {
reconnectAttempts: attempts,
lastConnectedAt,
endpoint: item.endpoint,
pendingPath: hostPendingPaths[item.id] ?? null
pendingPath: hostPendingPaths[item.id] ?? null,
pairingRejected: hostPairingRejected[item.id] ?? false
})
return (
<MobileHostCard
+6
View File
@@ -261,6 +261,12 @@ platform :ios do
distribute_only: true,
skip_waiting_for_build_processing: false,
wait_processing_timeout_duration: TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS,
# submit_beta_review is on by default, so a superseded build of this same
# version train still sitting in beta review blocks the new submission
# ("Another build is in review" killed the 2026-08-05/08-06 runs).
# Fastlane filters by this build's preReleaseVersion, so it can only
# expire a build of the train this run is already replacing.
reject_build_waiting_for_review: true,
distribute_external: true,
groups: TESTFLIGHT_GROUPS,
notify_external_testers: true,
@@ -0,0 +1,126 @@
require "minitest/autorun"
require "ripper"
# Pins the upload_to_testflight arguments that two release outages proved a
# release cannot run without. `bundle exec fastlane lanes` loads and parses the
# Fastfile but exits 0 with either argument missing — both failures only surface
# once a real release is already mid-flight — so the arguments are asserted here
# statically instead.
module FastfileTestflightArguments
module_function
# [{ "app_platform" => "ios", ... }] for every upload_to_testflight call in
# `source`. Values are flattened to the first meaningful token so a keyword's
# presence and its literal can both be asserted.
def calls(source, action: "upload_to_testflight")
found = []
each_node(Ripper.sexp(source) || []) do |node|
next unless node[0] == :method_add_arg
next unless identifier(node[1]) == action
found << keyword_arguments(node[2])
end
found
end
def each_node(node, &block)
return unless node.is_a?(Array)
block.call(node) if node[0].is_a?(Symbol)
node.each { |child| each_node(child, &block) }
end
def identifier(node)
return nil unless node.is_a?(Array) && node[0] == :fcall
token(node[1], %i[@ident @const])
end
def keyword_arguments(node)
arguments = {}
each_node(node) do |child|
next unless child[0] == :assoc_new
label = token(child[1], [:@label])
arguments[label.delete_suffix(":")] = describe_value(child[2]) if label
end
arguments
end
# Strings collapse to their content; everything else (true, a constant, a
# local) collapses to its first token, which is enough to assert intent.
def describe_value(node)
contents = []
each_node(node) { |child| contents << child[1] if child[0] == :@tstring_content }
return contents.join unless contents.empty?
token(node, %i[@kw @const @ident @int])
end
def token(node, types)
found = nil
each_node(node) do |child|
found ||= child[1] if types.include?(child[0]) && child[1].is_a?(String)
end
found
end
end
class FastfileTestflightArgumentsTest < Minitest::Test
FASTFILE = File.expand_path("Fastfile", __dir__).freeze
def setup
@calls = FastfileTestflightArguments.calls(File.read(FASTFILE))
end
# Anchor: a parse that silently finds nothing would make every other
# assertion here pass vacuously.
def test_finds_the_upload_and_distribute_calls
assert_operator(@calls.size, :>=, 2, "expected upload_to_testflight in the build and distribute lanes")
end
def test_distribute_only_uploads_pass_the_app_platform
distribute_only_calls.each do |arguments|
# Without a local .ipa to sniff, pilot falls through to an interactive
# platform prompt and crashes the ubuntu distribute job (#14087).
assert_equal("ios", arguments["app_platform"])
end
end
def test_distribute_only_uploads_clear_a_build_stuck_in_beta_review
distribute_only_calls.each do |arguments|
# submit_beta_review defaults on, so a superseded same-train build left in
# beta review fails the submission with "Another build is in review".
assert_equal("true", arguments["reject_build_waiting_for_review"])
end
end
# The detector itself must be able to go red, or it is not a gate.
def test_reports_a_distribute_only_call_that_omits_the_app_platform
source = <<~RUBY
lane :distribute_testflight do
upload_to_testflight(
api_key: api_key,
distribute_only: true,
groups: TESTFLIGHT_GROUPS,
)
end
RUBY
arguments = FastfileTestflightArguments.calls(source).first
assert_equal("true", arguments["distribute_only"])
assert_nil(arguments["app_platform"])
end
private
# Asserting on an empty selection would pass vacuously, so the lane's presence
# is part of the contract.
def distribute_only_calls
calls = @calls.select { |arguments| arguments["distribute_only"] == "true" }
refute_empty(calls, "expected a distribute_only upload_to_testflight call")
calls
end
end
@@ -84,7 +84,9 @@ describe('MobileNativeChatComposer', () => {
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' hello')
// Verbatim: the send seam trims for the wire, so a rejected send can hand
// back the draft byte-for-byte (#14819).
expect(onSend).toHaveBeenCalledWith(' hello ')
expect(onChangeText).not.toHaveBeenCalled()
})
@@ -120,7 +122,7 @@ describe('MobileNativeChatComposer', () => {
)
})
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
expect(onSend).toHaveBeenCalledWith(' /clear is prose ')
})
it('locks the option pickers while a composer send is in flight', async () => {
@@ -219,7 +221,7 @@ describe('MobileNativeChatComposer', () => {
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' hello')
expect(onSend).toHaveBeenCalledWith(' hello ')
expect(onChangeText).not.toHaveBeenCalled()
})
@@ -151,7 +151,9 @@ export function MobileNativeChatComposer({
sendingRef.current = true
setSending(true)
try {
const accepted = await onSend(value.trimEnd())
// Raw, not trimmed: the send seam owns the wire trim, and a rejection has
// to hand the user back exactly what they typed (#14819).
const accepted = await onSend(value)
if (accepted) {
setCursor(0)
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { resolveActiveSessionTab } from './active-session-tab'
type Tab = { id: string; type: 'terminal' | 'browser'; isActive: boolean }
function terminalTab(id: string, isActive: boolean): Tab {
return { id, type: 'terminal', isActive }
}
function browserTab(id: string, isActive: boolean): Tab {
return { id, type: 'browser', isActive }
}
describe('resolveActiveSessionTab', () => {
it('keeps the device on its browser tab when a republication flags the agent terminal active', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', true), browserTab('browser', false)],
{
pendingActiveSessionTabId: null,
selectedSessionTabId: 'browser'
}
)
expect(result.activeTab?.id).toBe('browser')
expect(result.selectionSource).toBe('selected-tab')
expect(result.retainSelectedSessionTabId).toBe(false)
})
it('keeps the device on a terminal tab when an agent turn flags another terminal active', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', true), terminalTab('shell', false)],
{
pendingActiveSessionTabId: null,
selectedSessionTabId: 'shell'
}
)
expect(result.activeTab?.id).toBe('shell')
})
it('follows the host when it explicitly navigates this device', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', true), browserTab('browser', false)],
{
pendingActiveSessionTabId: null,
selectedSessionTabId: 'browser',
navigationIntent: 'follow'
}
)
expect(result.activeTab?.id).toBe('agent')
expect(result.selectionSource).toBe('navigation-intent')
expect(result.retainSelectedSessionTabId).toBe(false)
})
it('lets explicit follow supersede an in-flight local activation', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', true), browserTab('browser', false)],
{
pendingActiveSessionTabId: 'browser',
selectedSessionTabId: 'browser',
navigationIntent: 'follow'
}
)
expect(result.activeTab?.id).toBe('agent')
expect(result.selectionSource).toBe('navigation-intent')
expect(result.clearPendingActiveSessionTabId).toBe(true)
})
it('falls back to the snapshot but retains the pick while the selected tab is missing', () => {
const gap = resolveActiveSessionTab([terminalTab('agent', true)], {
pendingActiveSessionTabId: null,
selectedSessionTabId: 'browser'
})
expect(gap.activeTab?.id).toBe('agent')
expect(gap.retainSelectedSessionTabId).toBe(true)
// The guest re-registers and the tab comes back: focus must return to it.
const restored = resolveActiveSessionTab(
[terminalTab('agent', true), browserTab('browser', false)],
{ pendingActiveSessionTabId: null, selectedSessionTabId: 'browser' }
)
expect(restored.activeTab?.id).toBe('browser')
})
it('lets a pending activation win until the snapshot catches up', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', true), browserTab('browser', false)],
{
pendingActiveSessionTabId: 'browser',
selectedSessionTabId: 'agent'
}
)
expect(result.activeTab?.id).toBe('browser')
expect(result.selectionSource).toBe('pending-tab')
expect(result.clearPendingActiveSessionTabId).toBe(false)
})
it('clears the pending marker once the snapshot acknowledges it', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', false), browserTab('browser', true)],
{
pendingActiveSessionTabId: 'browser',
selectedSessionTabId: 'agent'
}
)
expect(result.activeTab?.id).toBe('browser')
expect(result.clearPendingActiveSessionTabId).toBe(true)
})
it('clears a stale pending marker whose tab is gone', () => {
const result = resolveActiveSessionTab([terminalTab('agent', true)], {
pendingActiveSessionTabId: 'removed',
selectedSessionTabId: null
})
expect(result.activeTab?.id).toBe('agent')
expect(result.clearPendingActiveSessionTabId).toBe(true)
})
it('uses the snapshot when the device has no pick yet', () => {
const result = resolveActiveSessionTab(
[terminalTab('agent', false), browserTab('browser', true)],
{
pendingActiveSessionTabId: null,
selectedSessionTabId: null
}
)
expect(result.activeTab?.id).toBe('browser')
expect(result.selectionSource).toBe('snapshot')
})
it('returns null for an empty snapshot', () => {
expect(
resolveActiveSessionTab([], { pendingActiveSessionTabId: null, selectedSessionTabId: 'x' })
.activeTab
).toBeNull()
})
})
+68
View File
@@ -0,0 +1,68 @@
type SessionTabLike = {
id: string
isActive: boolean
}
export type ActiveSessionTabSelectionSource =
| 'snapshot'
| 'pending-tab'
| 'selected-tab'
| 'navigation-intent'
export type ResolveActiveSessionTabResult<T extends SessionTabLike> = {
activeTab: T | null
selectionSource: ActiveSessionTabSelectionSource
clearPendingActiveSessionTabId: boolean
retainSelectedSessionTabId: boolean
}
export function resolveActiveSessionTab<T extends SessionTabLike>(
tabs: readonly T[],
opts: {
pendingActiveSessionTabId: string | null
selectedSessionTabId: string | null
navigationIntent?: 'follow'
}
): ResolveActiveSessionTabResult<T> {
const snapshotActive = tabs.find((tab) => tab.isActive) ?? tabs[0] ?? null
const pendingActiveSessionTabId = opts.pendingActiveSessionTabId
// Why: targeted follow is the only host action allowed to supersede phone-local intent.
if (opts.navigationIntent === 'follow') {
return {
activeTab: snapshotActive,
selectionSource: 'navigation-intent',
clearPendingActiveSessionTabId: pendingActiveSessionTabId !== null,
retainSelectedSessionTabId: false
}
}
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
return {
activeTab: snapshotActive,
selectionSource: 'snapshot',
clearPendingActiveSessionTabId: true,
retainSelectedSessionTabId: false
}
}
const pendingTab = tabs.find((tab) => tab.id === pendingActiveSessionTabId) ?? null
if (pendingTab) {
return {
activeTab: pendingTab,
selectionSource: 'pending-tab',
clearPendingActiveSessionTabId: false,
retainSelectedSessionTabId: false
}
}
}
const clearPendingActiveSessionTabId = pendingActiveSessionTabId !== null
const selectedTab = opts.selectedSessionTabId
? (tabs.find((tab) => tab.id === opts.selectedSessionTabId) ?? null)
: null
return {
activeTab: selectedTab ?? snapshotActive,
selectionSource: selectedTab ? 'selected-tab' : 'snapshot',
clearPendingActiveSessionTabId,
// Why: transient browser guest swaps must not erase the device's explicit pick.
retainSelectedSessionTabId: Boolean(opts.selectedSessionTabId) && !selectedTab
}
}
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
}
function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}
function unresolved(
id: string,
text: string,
expectedOccurrence = 1
): MobileNativeChatPendingMessage {
return { id, text, expectedOccurrence, baselineTailMessageId: null, baselineResolved: false }
}
describe('rebaseMobileNativeChatPendingBaselines', () => {
const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)]
it('returns the same array when every baseline is already resolved', () => {
const pending: MobileNativeChatPendingMessage[] = [
{
id: 'p1',
text: 'hi',
expectedOccurrence: 1,
baselineTailMessageId: 'm2',
baselineResolved: true
}
]
expect(rebaseMobileNativeChatPendingBaselines(history, pending)).toBe(pending)
})
it('gives an unresolved send the loaded tail as its glue boundary', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', 'run the tests')])
).toEqual([
{
id: 'p1',
text: 'run the tests',
// NOT recounted against this read: the read may already carry this send's
// own echo, and counting it as history would strand the bubble forever.
expectedOccurrence: 1,
baselineTailMessageId: 'm2',
baselineResolved: true
}
])
})
it('pins to null when the authoritative transcript is empty', () => {
expect(rebaseMobileNativeChatPendingBaselines([], [unresolved('p1', 'hi')])).toEqual([
{
id: 'p1',
text: 'hi',
expectedOccurrence: 1,
baselineTailMessageId: null,
baselineResolved: true
}
])
})
it('keeps the ordinals the queue already assigned', () => {
expect(
rebaseMobileNativeChatPendingBaselines(history, [
unresolved('p1', 'run the tests', 1),
unresolved('p2', 'run the tests', 2)
]).map((item) => item.expectedOccurrence)
).toEqual([1, 2])
})
it('leaves already-resolved neighbours untouched', () => {
const resolved: MobileNativeChatPendingMessage = {
id: 'p1',
text: 'run the tests',
expectedOccurrence: 9,
baselineTailMessageId: 'm1',
baselineResolved: true
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
resolved,
unresolved('p2', 'run the tests', 2)
])
expect(rebased[0]).toBe(resolved)
expect(rebased[1]).toEqual({
id: 'p2',
text: 'run the tests',
expectedOccurrence: 2,
baselineTailMessageId: 'm2',
baselineResolved: true
})
})
// An unsettled read still shows this session's own retained history, so a send
// made across a reconnect already owns a real boundary. Moving it onto the read
// that follows pushes it past the send's own echo and strands the bubble.
it('never moves a tail the send actually captured', () => {
const captured: MobileNativeChatPendingMessage = {
id: 'p1',
text: 'run the tests',
expectedOccurrence: 1,
baselineTailMessageId: 'm1',
baselineResolved: false
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [captured])
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
expect(rebased[0]?.baselineResolved).toBe(true)
})
// An image echo counts image turns AFTER its tail, so moving a real tail onto
// this read would discard an echo the read already carries.
it('keeps a caption-less image echo on the tail it actually captured', () => {
const images = {
...unresolved('p1', ''),
baselineTailMessageId: 'm1',
images: ['file:///a.png']
}
const rebased = rebaseMobileNativeChatPendingBaselines(history, [images])
expect(rebased[0]?.baselineTailMessageId).toBe('m1')
expect(rebased[0]?.expectedOccurrence).toBe(1)
expect(rebased[0]?.baselineResolved).toBe(true)
})
// A captioned image echo binds by an ordinal counted over the whole transcript.
// Pinning a tail without recounting leaves it matching nothing, forever.
it('leaves a captioned image echo entirely alone', () => {
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
{ ...unresolved('p1', 'here'), images: ['file:///a.png'] }
])
expect(rebased[0]?.baselineTailMessageId).toBe(null)
expect(rebased[0]?.expectedOccurrence).toBe(1)
expect(rebased[0]?.baselineResolved).toBe(true)
})
// A supplied tail would sit at or after this send's own echo, and an image
// echo counts turns AFTER its tail — so it would exclude the very row it is
// waiting for, and image entries have no other retirement path.
it('never supplies a tail to a caption-less image echo either', () => {
const rebased = rebaseMobileNativeChatPendingBaselines(history, [
{ ...unresolved('p1', ''), images: ['file:///a.png'] },
{ ...unresolved('p2', '', 2), images: ['file:///b.png'] }
])
expect(rebased.map((item) => item.baselineTailMessageId)).toEqual([null, null])
expect(rebased.map((item) => item.expectedOccurrence)).toEqual([1, 2])
expect(rebased.every((item) => item.baselineResolved)).toBe(true)
})
// Same rule for a text-empty entry with no images: it reconciles by counting
// image-source turns after its tail, so a supplied tail strands it too.
it('never supplies a tail to a text-empty entry', () => {
const rebased = rebaseMobileNativeChatPendingBaselines(history, [unresolved('p1', ' ')])
expect(rebased[0]?.baselineTailMessageId).toBe(null)
expect(rebased[0]?.baselineResolved).toBe(true)
})
})
@@ -0,0 +1,53 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
/**
* Give the sends that never saw a transcript the boundary they lack, on the
* first settled read.
*
* Such a send has no row to judge candidates against — which held it out of
* matching entirely, stranding it as a queued bubble and, worse, as a permanent
* glue barrier for its neighbours.
*
* Only a send that captured NO tail is pinned. An unsettled read still shows
* this session's own retained history (see `createNativeChatTranscriptRetention`
* — a reconnect or a failed read keeps the conversation on screen rather than
* blanking it), so a send made then already owns a correct boundary. Moving it
* onto this read would push it past the send's own echo and strand the bubble
* for good.
*
* The ordinal is deliberately NOT recounted. The read can already carry the
* send's own echo — a re-subscribe returns whatever exists now — and nothing
* local separates that echo from an identical older prompt: the transport writes
* keystrokes into a TUI and carries no message id, and row timestamps come from
* the host while the send time comes from the phone. Counting it as history puts
* the ordinal one past anything the transcript can supply.
*
* And only a TEXT-bearing send is given one. The glue matcher is the sole
* consumer that a supplied tail helps; every other one is harmed by it. An image
* echo reconciles by counting turns AFTER its tail, so a tail taken from a read
* that already carries its echo excludes that echo and the bubble can never
* retire — image entries have no other retirement path. A captioned one is worse
* still: it binds by an ordinal counted over the whole transcript, so a tail
* without a matching recount leaves it claiming nothing at all.
*/
export function rebaseMobileNativeChatPendingBaselines(
messages: readonly NativeChatMessage[],
current: MobileNativeChatPendingMessage[]
): MobileNativeChatPendingMessage[] {
if (current.every((item) => item.baselineResolved)) {
return current
}
const baselineTailMessageId = messages.at(-1)?.id ?? null
return current.map((item) => {
if (item.baselineResolved) {
return item
}
const reconcilesAgainstItsOwnTail =
Boolean(item.images?.length) || normalizeReconcileText(item.text) === ''
return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail
? { ...item, baselineResolved: true }
: { ...item, baselineResolved: true, baselineTailMessageId }
})
}
@@ -5,6 +5,11 @@ export type MobileNativeChatPendingMessage = {
/** Local preview URIs carried by the send for its optimistic echo. */
images?: string[]
baselineTailMessageId: string | null
/** Whether the transcript this baseline was captured from was already this
* session's own history. A send issued mid-hydration is captured unresolved
* and rebased onto the first authoritative read instead of reconciling
* against rows that may belong to another tab. */
baselineResolved: boolean
}
export type MobileNativeChatSendOrigin = {
@@ -13,6 +18,7 @@ export type MobileNativeChatSendOrigin = {
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
baselineResolved: boolean
}
type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
@@ -56,6 +62,7 @@ export function appendMobileNativeChatPending(
? expectedImageEchoOrdinal
: origin.baselineOccurrences + earlierOutstanding + 1,
baselineTailMessageId: origin.baselineTailMessageId,
baselineResolved: origin.baselineResolved,
...(images?.length ? { images } : {})
}
]
@@ -0,0 +1,323 @@
import { describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
import {
GLUE_SLIDE_BUDGET,
retireLandedMobileNativeChatPending,
selectGluedPendingIds
} from './mobile-native-chat-pending-retirement'
const NO_IMAGE_ECHOES: ReadonlySet<string> = new Set()
function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
}
function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}
/** A send captured with `tail` as the last transcript message it could see. */
function pendingSend(
id: string,
text: string,
tail: string | null,
expectedOccurrence = 1,
baselineResolved = true
): MobileNativeChatPendingMessage {
return { id, text, expectedOccurrence, baselineTailMessageId: tail, baselineResolved }
}
function retiredIds(
messages: readonly NativeChatMessage[],
pending: readonly MobileNativeChatPendingMessage[]
): string[] {
return [...selectGluedPendingIds(messages, pending)].sort()
}
describe('selectGluedPendingIds', () => {
it('retires both bubbles when two rapid sends collapse into one user row', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'run the tests again', 5000)
]
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
})
it('retires a glued row that concatenated with no separator at all', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'run the testsagain', 5000)
]
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
})
it('retires three collapsed prompts in one row', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two three', 5000)]
const pending = [
pendingSend('p1', 'one', 'm1'),
pendingSend('p2', 'two', 'm1'),
pendingSend('p3', 'three', 'm1')
]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3'])
})
it('matches across tab, newline and repeated-space separators', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'run the tests\t\n again', 5000)
]
const pending = [
pendingSend('p1', ' run the tests\n', 'm1'),
pendingSend('p2', '\tagain ', 'm1')
]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
})
it('matches prompts that themselves contain the separator', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'fix the bug and run the tests', 5000)
]
const pending = [
pendingSend('p1', 'fix the bug', 'm1'),
pendingSend('p2', 'and run the tests', 'm1')
]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
})
it('matches when one pending is a strict prefix of the next', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run run the tests', 5000)]
const pending = [pendingSend('p1', 'run', 'm1'), pendingSend('p2', 'run the tests', 'm1')]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
})
it('matches identical prompts sent twice and three times', () => {
const twice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi', 5000)]
expect(
retiredIds(twice, [pendingSend('p1', 'hi', 'm1', 1), pendingSend('p2', 'hi', 'm1', 2)])
).toEqual(['p1', 'p2'])
const thrice = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'hi hi hi', 5000)]
expect(
retiredIds(thrice, [
pendingSend('p1', 'hi', 'm1', 1),
pendingSend('p2', 'hi', 'm1', 2),
pendingSend('p3', 'hi', 'm1', 3)
])
).toEqual(['p1', 'p2', 'p3'])
})
it('retires two separate glued rows against their own runs', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'one two', 5000),
userTurn('m3', 'three four', 6000)
]
const pending = [
pendingSend('p1', 'one', 'm1'),
pendingSend('p2', 'two', 'm1'),
pendingSend('p3', 'three', 'm2'),
pendingSend('p4', 'four', 'm2')
]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2', 'p3', 'p4'])
})
// FP1: the concatenation already exists in history, older than the sends.
it('never lets a transcript row that predates the sends retire them', () => {
const messages = [
userTurn('m1', 'run the tests again', 1),
assistantTurn('m2', 'done', 2),
assistantTurn('m3', 'anything else?', 3)
]
const pending = [pendingSend('p1', 'run the tests', 'm3'), pendingSend('p2', 'again', 'm3')]
expect(retiredIds(messages, pending)).toEqual([])
})
// FP2: an old turn reads like the concatenation of two much later sends.
it('never lets an older turn retire sends captured after it', () => {
const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')]
expect(retiredIds(messages, pending)).toEqual([])
})
it('rejects a row the pending run only prefixes', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'run the tests again with coverage', 5000)
]
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
expect(retiredIds(messages, pending)).toEqual([])
})
it('rejects a run that only partly spells the row', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'run the tests', 5000)]
const pending = [pendingSend('p1', 'run the tests', 'm1'), pendingSend('p2', 'again', 'm1')]
// A lone exact match is an ordinary landing, not glue.
expect(retiredIds(messages, pending)).toEqual([])
})
it('rejects glue when a send in the run cannot resolve its own tail', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'paginated-away')]
expect(retiredIds(messages, pending)).toEqual([])
})
it('treats a null tail as "transcript was empty", so any row can glue', () => {
const messages = [userTurn('m1', 'one two', 5000)]
const pending = [pendingSend('p1', 'one', null), pendingSend('p2', 'two', null)]
expect(retiredIds(messages, pending)).toEqual(['p1', 'p2'])
})
// Unresolved baselines are held out of matching until the drafts hook rebases
// them onto the first authoritative read — see mobile-native-chat-pending-baseline.
it('holds out sends whose baseline has not been rebased onto a real transcript', () => {
const messages = [userTurn('m1', 'one two', 1000)]
const pending = [
pendingSend('p1', 'one', null, 1, false),
pendingSend('p2', 'two', null, 1, false)
]
expect(retiredIds(messages, pending)).toEqual([])
})
// A head that can never match must not freeze the run behind it: one stuck
// bubble would otherwise disable glue retirement for the rest of the session.
it('glues a later pair even when the head of the run can never match', () => {
const messages = [
userTurn('m0', 'unrelated', 1000),
userTurn('m1', 'fix the bug', 5000),
userTurn('m2', 'ship the fix', 6000)
]
const stuckHead = pendingSend('p0', 'bug', 'm0')
const pair = [pendingSend('p1', 'ship the', 'm0'), pendingSend('p2', 'fix', 'm0')]
expect(retiredIds(messages, [stuckHead, ...pair])).toEqual(['p1', 'p2'])
})
// Nothing bounds how many sends pile onto the agent's input line, so the
// slide's budget must never truncate a genuine glue.
it('retires a glued run far longer than the slide budget', () => {
const words = Array.from({ length: 12 }, (_, index) => `w${index}`)
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', words.join(' '), 5000)]
const pending = words.map((word, index) => pendingSend(`p${index}`, word, 'm1'))
expect(retiredIds(messages, pending)).toHaveLength(words.length)
})
it('skips a caption-less image echo instead of gluing it', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
const pending = [
pendingSend('p1', '', 'm1'),
pendingSend('p2', 'one', 'm1'),
pendingSend('p3', 'two', 'm1')
]
expect(retiredIds(messages, pending)).toEqual(['p2', 'p3'])
})
it('keeps a captioned image echo and its neighbors until the preview is rebound', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
const pending = [
{ ...pendingSend('p1', 'one', 'm1'), images: ['file:///a.png'] },
pendingSend('p2', 'two', 'm1')
]
expect(retiredIds(messages, pending)).toEqual([])
expect(
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
).toEqual(['p1', 'p2'])
})
it('does nothing for a single pending send', () => {
const messages = [assistantTurn('m1', 'ready', 1000), userTurn('m2', 'one two', 5000)]
expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([])
})
// The cursor slides past a head that cannot match, so a turn may try several
// start positions — but one inspection budget covers the whole slide, keeping
// the work linear in the run length rather than quadratic. The attempt already
// in flight is allowed to overshoot the remaining budget, deliberately: that is
// what guarantees a genuine long glue is never truncated.
it('keeps segment inspection linear in the run length per transcript turn', () => {
const pendingCount = 96
const turnCount = 12
const text = `${'a '.repeat(pendingCount)}x`
const messages = [
assistantTurn('tail', 'ready', 1000),
...Array.from({ length: turnCount }, (_, index) => userTurn(`m${index}`, text, 2000 + index))
]
const pending = Array.from({ length: pendingCount }, (_, index) =>
pendingSend(`p${index}`, 'a', 'tail', index + 1)
)
const startsWith = vi.spyOn(String.prototype, 'startsWith')
let segmentChecks = 0
try {
expect(retiredIds(messages, pending)).toEqual([])
segmentChecks = startsWith.mock.calls.length
} finally {
startsWith.mockRestore()
}
expect(segmentChecks).toBeLessThanOrEqual(turnCount * (2 * pendingCount + GLUE_SLIDE_BUDGET))
})
})
describe('retireLandedMobileNativeChatPending', () => {
it('retires exact landings by ordinal and glued rows in the same pass', () => {
const messages = [
assistantTurn('m1', 'ready', 1000),
userTurn('m2', 'standalone', 4000),
userTurn('m3', 'run the tests again', 5000)
]
const pending = [
pendingSend('p0', 'standalone', 'm1'),
pendingSend('p1', 'run the tests', 'm2'),
pendingSend('p2', 'again', 'm2')
]
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toEqual([])
})
// The drafts effect early-outs on `next === current`; a fresh array on the
// no-op path would re-enter it on every transcript frame.
it('returns the input array itself when nothing retires', () => {
const messages = [assistantTurn('m1', 'ready', 1000)]
const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'm1')]
expect(retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES)).toBe(pending)
})
it('keeps sends whose glue candidate predates them', () => {
const messages = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
const pending = [pendingSend('p1', 'fix the', 'm2'), pendingSend('p2', 'bug', 'm2')]
expect(
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
).toEqual(['p1', 'p2'])
})
it('does not glue former neighbors across an exact-landed send', () => {
const messages = [
assistantTurn('m0', 'ready', 1000),
userTurn('m1', 'middle', 2000),
userTurn('m2', 'one two', 3000)
]
const pending = [
pendingSend('p1', 'one', 'm0'),
pendingSend('p2', 'middle', 'm0'),
pendingSend('p3', 'two', 'm0')
]
expect(
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
).toEqual(['p1', 'p3'])
})
it('keeps image echoes until their preview is rebound', () => {
const messages = [assistantTurn('m1', 'ready', 1000)]
const pending = [{ ...pendingSend('p1', 'photo', 'm1'), images: ['file:///a.png'] }]
expect(
retireLandedMobileNativeChatPending(messages, pending, NO_IMAGE_ECHOES).map((item) => item.id)
).toEqual(['p1'])
expect(retireLandedMobileNativeChatPending(messages, pending, new Set(['p1']))).toEqual([])
})
})
@@ -0,0 +1,173 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
countImageSourceTurnsAfter,
normalizeReconcileText,
normalizedUserText
} from './mobile-native-chat-draft-reconcile'
import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo'
const SPACE = ' '
const NO_PENDING_IDS: ReadonlySet<string> = new Set()
// Slack the cursor slide may spend re-trying later start positions, on top of
// one free pass over the run. Nothing bounds how many sends accumulate on the
// agent's input line — that ends when the agent accepts input again — so the
// budget must never truncate a genuine glue: the first attempt covers the whole
// run and always fits. It only stops a run of identical prefix-matching sends
// from making the scan quadratic.
export const GLUE_SLIDE_BUDGET = 8
type UserTurn = { index: number; text: string }
type GlueSegment = { text: string; tail: number } | null
/** Pending ids represented by post-send transcript rows that glued adjacent sends. */
export function selectGluedPendingIds(
messages: readonly NativeChatMessage[],
pending: readonly MobileNativeChatPendingMessage[],
excludedPendingIds: ReadonlySet<string> = NO_PENDING_IDS
): ReadonlySet<string> {
const retired = new Set<string>()
if (pending.length < 2) {
return retired
}
const messageIndexById = new Map<string, number>()
const turns: UserTurn[] = []
for (const [index, message] of messages.entries()) {
messageIndexById.set(message.id, index)
const text = normalizedUserText(message)
if (text) {
turns.push({ index, text })
}
}
const segments: GlueSegment[] = pending.map((item) => {
const text = normalizeReconcileText(item.text)
const tail =
item.baselineTailMessageId === null
? -1
: (messageIndexById.get(item.baselineTailMessageId) ?? null)
return excludedPendingIds.has(item.id) ||
!item.baselineResolved ||
item.images?.length ||
text === '' ||
tail === null
? null
: { text, tail }
})
// Barriers preserve original adjacency after exact landings retire.
let runStart = 0
while (runStart < pending.length) {
while (runStart < pending.length && segments[runStart] === null) {
runStart += 1
}
let runEnd = runStart
while (runEnd < pending.length && segments[runEnd] !== null) {
runEnd += 1
}
let cursor = runStart
for (const turn of turns) {
if (cursor >= runEnd - 1) {
break
}
// A send that can never match must not freeze the run behind it. One
// permanently unretirable head — a pair whose own glued row arrived with
// the read, or a send the count pass claimed against an older row — would
// otherwise disable glue retirement for every later pair, for the rest of
// the session. Slide past it; the cursor stays monotonic, so a later turn
// can never claim a send an earlier one already took.
let budget = runEnd - runStart + GLUE_SLIDE_BUDGET
let start = cursor
let matched = 0
for (; start <= runEnd - 2 && budget > 0; start++) {
const attempt = matchGluedRun(turn, segments, start, runEnd)
budget -= attempt.inspected
matched = attempt.matched
if (matched > 0) {
break
}
}
if (matched === 0) {
continue
}
for (let index = start; index < start + matched; index++) {
retired.add(pending[index]!.id)
}
cursor = start + matched
}
runStart = runEnd + 1
}
return retired
}
/** Length of the exact glued run at `start`, plus the segments it had to read. */
function matchGluedRun(
turn: UserTurn,
segments: readonly GlueSegment[],
start: number,
end: number
): { matched: number; inspected: number } {
let at = 0
let matched = 0
let inspected = 0
for (let index = start; index < end; index++) {
const segment = segments[index]!
inspected += 1
// Every send carries its OWN boundary: a row that already existed when this
// send was issued can never be part of its echo, however well it reads.
if (turn.index <= segment.tail) {
return { matched: 0, inspected }
}
if (at > 0 && turn.text[at] === SPACE) {
at += 1
}
if (!turn.text.startsWith(segment.text, at)) {
return { matched: 0, inspected }
}
at += segment.text.length
matched += 1
if (at === turn.text.length) {
// A lone exact match is an ordinary landing, which the count pass owns.
return { matched: matched > 1 ? matched : 0, inspected }
}
}
return { matched: 0, inspected }
}
/** Retires exact and glued transcript landings while preserving pending order. */
export function retireLandedMobileNativeChatPending(
messages: readonly NativeChatMessage[],
current: MobileNativeChatPendingMessage[],
landedImagePendingIds: ReadonlySet<string>
): MobileNativeChatPendingMessage[] {
const landedCounts = new Map<string, number>()
for (const message of messages) {
const text = normalizedUserText(message)
if (text) {
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
}
}
const landedPendingIds = new Set<string>()
for (const item of current) {
if (landedImagePendingIds.has(item.id)) {
landedPendingIds.add(item.id)
continue
}
// Keep image echoes until their local preview reaches the authoritative message.
// An unresolved baseline has nothing to count against yet — `messages` is not
// known to be the transcript this send was issued into.
if (item.images?.length || !item.baselineResolved) {
continue
}
const landed =
item.text.trim() === ''
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >=
item.expectedOccurrence
: (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence
if (landed) {
landedPendingIds.add(item.id)
}
}
const glued = selectGluedPendingIds(messages, current, landedPendingIds)
return landedPendingIds.size === 0 && glued.size === 0
? current
: current.filter((item) => !landedPendingIds.has(item.id) && !glued.has(item.id))
}
@@ -61,6 +61,8 @@ export type SessionTabsResult = {
tabs: MobileSessionTab[]
activeTabId: string | null
activeTabType: MobileSessionTabType | null
/** Host explicitly navigated this device (desktop/CLI `navigation: clients|all`), not a plain republication. */
navigationIntent?: 'follow'
}
export type RuntimeStatusResult = {
@@ -96,7 +96,8 @@ const ORIGIN = {
pendingKey: 'h\0w\0tab-1\0session-1',
normalizedText: 'look',
baselineOccurrences: 0,
baselineTailMessageId: null
baselineTailMessageId: null,
baselineResolved: true
}
describe('useMobileNativeChatController handleNativeChatSend', () => {
@@ -111,7 +111,8 @@ export function useMobileNativeChatController(args: {
// a null is indistinguishable from a host retraction, and peeking at the
// terminal view would permanently decline the prefill.
chatActive: showNativeChat,
transcriptLoading: nativeChatSession.transcriptLoading
transcriptLoading: nativeChatSession.transcriptLoading,
transcriptSettled: nativeChatSession.status === 'ready'
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
@@ -0,0 +1,359 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { buildMobileNativeChatTransientData } from './mobile-native-chat-render-data'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
type TestRenderer = {
unmount(): void
update(element: ReturnType<typeof createElement>): void
}
function userTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
}
function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}
function imageTurn(id: string, path: string, timestamp: number): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text: `[Image: source: ${path}]` }],
timestamp,
source: 'transcript'
}
}
/** Texts of the optimistic bubbles the chat list actually renders. */
function renderedPendingTexts(
messages: NativeChatMessage[],
state: DraftState | null
): (string | undefined)[] {
const { data } = buildMobileNativeChatTransientData({
folded: messages,
streaming: null,
pending: state?.pending ?? []
})
return data
.filter((message) => !messages.some((existing) => existing.id === message.id))
.map((message) => message.blocks.find((block) => block.type === 'text')?.text)
}
describe('useMobileNativeChatDrafts glued pending sends', () => {
let renderer: TestRenderer | null = null
let state: DraftState | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({
messages,
transcriptLoading = false,
transcriptSettled = !transcriptLoading
}: {
messages: NativeChatMessage[]
transcriptLoading?: boolean
transcriptSettled?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId: 'tab',
sessionId: 'session',
messages,
transcriptLoading,
transcriptSettled
})
return null
}
async function mount(
messages: NativeChatMessage[],
transcriptLoading = false,
transcriptSettled = !transcriptLoading
): Promise<void> {
await act(async () => {
renderer = create(createElement(Harness, { messages, transcriptLoading, transcriptSettled }))
})
}
async function update(
messages: NativeChatMessage[],
transcriptLoading = false,
transcriptSettled = !transcriptLoading
): Promise<void> {
await act(async () =>
renderer?.update(createElement(Harness, { messages, transcriptLoading, transcriptSettled }))
)
}
function send(text: string): void {
const origin = state?.captureSendOrigin(text)
if (origin) {
state?.acceptSend(origin, text)
}
}
/** Two composer sends fired before either transcript echo lands. */
function rapidSend(first: string, second: string): void {
act(() => {
send(first)
send(second)
})
}
const pendingTexts = (): (string | undefined)[] | undefined =>
state?.pending.map((item) => item.text)
it('retires both bubbles when the rapid sends land as one glued user row', async () => {
const history = [assistantTurn('m1', 'ready', 1000)]
await mount(history)
rapidSend('run the tests', 'again')
expect(pendingTexts()).toEqual(['run the tests', 'again'])
const glued = [...history, userTurn('m2', 'run the tests again', 5000)]
await update(glued)
expect(state?.pending).toEqual([])
expect(renderedPendingTexts(glued, state)).toEqual([])
})
// FP1: the concatenation already sits in history, older than the sends.
it('keeps and renders both bubbles when the matching row predates the sends', async () => {
const history = [
userTurn('m1', 'run the tests again', 1000),
assistantTurn('m2', 'done', 2000),
assistantTurn('m3', 'anything else?', 3000)
]
await mount(history)
rapidSend('run the tests', 'again')
await update([...history])
expect(pendingTexts()).toEqual(['run the tests', 'again'])
expect(renderedPendingTexts(history, state)).toEqual(['run the tests', 'again'])
})
// FP2: an older turn reads like the concatenation of two much later sends.
it('keeps and renders both bubbles when an older turn spells them out', async () => {
const history = [userTurn('m1', 'fix the bug', 1000), assistantTurn('m2', 'fixed', 1100)]
await mount(history)
rapidSend('fix the', 'bug')
await update([...history])
expect(pendingTexts()).toEqual(['fix the', 'bug'])
expect(renderedPendingTexts(history, state)).toEqual(['fix the', 'bug'])
})
it('still retires each bubble on its own when the sends do not glue', async () => {
const history = [assistantTurn('m1', 'ready', 1000)]
await mount(history)
rapidSend('run the tests', 'again')
const separate = [
...history,
userTurn('m2', 'run the tests', 5000),
userTurn('m3', 'again', 5100)
]
await update(separate)
expect(state?.pending).toEqual([])
})
it('keeps the second bubble when only the first send has echoed', async () => {
const history = [assistantTurn('m1', 'ready', 1000)]
await mount(history)
rapidSend('run the tests', 'again')
const partial = [...history, userTurn('m2', 'run the tests', 5000)]
await update(partial)
expect(pendingTexts()).toEqual(['again'])
expect(renderedPendingTexts(partial, state)).toEqual(['again'])
})
// STA-4492 / #14819: sends issued mid-hydration used to be marked permanently
// untrusted, so their glued row could never retire them and they also barred
// their neighbours from matching. The first authoritative read rebases them
// instead: history that arrives with it is still not theirs, but everything
// after it is.
describe('sends issued while the transcript is still hydrating', () => {
const loadedHistory = [userTurn('m1', 'run the tests again', 1000)]
it('keeps both bubbles when the loaded history only looks like their glue', async () => {
await mount([], true)
rapidSend('run the tests', 'again')
await update(loadedHistory)
expect(pendingTexts()).toEqual(['run the tests', 'again'])
expect(renderedPendingTexts(loadedHistory, state)).toEqual(['run the tests', 'again'])
})
it('retires both bubbles once their glued row lands after hydration', async () => {
await mount([], true)
rapidSend('run the tests', 'again')
await update(loadedHistory)
const glued = [...loadedHistory, userTurn('m2', 'run the tests again', 5000)]
await update(glued)
expect(state?.pending).toEqual([])
expect(renderedPendingTexts(glued, state)).toEqual([])
})
it('retires a hydration-time send on its own exact echo', async () => {
await mount([], true)
act(() => send('run the tests'))
await update(loadedHistory)
expect(pendingTexts()).toEqual(['run the tests'])
const echoed = [...loadedHistory, userTurn('m2', 'run the tests', 5000)]
await update(echoed)
expect(state?.pending).toEqual([])
})
it('stops barring a later pair from gluing once its own echo lands', async () => {
await mount([], true)
act(() => send('hold this'))
await update(loadedHistory)
rapidSend('fix the', 'bug')
const landed = [
...loadedHistory,
userTurn('m2', 'hold this', 5000),
userTurn('m3', 'fix the bug', 5100)
]
await update(landed)
expect(state?.pending).toEqual([])
})
// The read that resolves a hydration-time send can already contain that
// send's own echo — a re-subscribe returns whatever exists now — and nothing
// local tells that echo from an identical older prompt. Claiming the row is
// the lesser evil, not a free one: normally the real echo lands a round trip
// later, but a send that never landed and was claimed by an older identical
// row stops being tracked at all — worst for short repeats ("yes", "1").
// Holding instead costs a queued bubble that never clears AND a run that can
// never glue again, for the rest of the session.
it('retires a hydration-time send whose echo arrives with the read', async () => {
await mount([], true)
act(() => send('run the tests'))
const echoed = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'passed', 1100)]
await update(echoed)
expect(state?.pending).toEqual([])
})
// The same read seen one reconnect later: the send left, the agent echoed it,
// and only then did the first authoritative read land.
it('retires a hydration-time send after a reconnect delivered the read late', async () => {
await mount([], true)
act(() => send('run the tests'))
// Still loading: the first read never arrived before the drop.
await update([], true)
const afterReconnect = [
userTurn('m1', 'run the tests', 5000),
assistantTurn('m2', 'ok', 5100)
]
await update(afterReconnect)
expect(state?.pending).toEqual([])
// Nothing later resurrects it, and the run stays glue-capable.
await update([...afterReconnect, assistantTurn('m3', 'all green', 6000)])
expect(state?.pending).toEqual([])
})
// A reconnect leaves this session's own retained history on screen, so sends
// made across it own a real boundary already. Moving it onto the read that
// follows would push it past their own glued row and strand both bubbles.
it('retires a pair sent across a reconnect whose glued row arrives with the read', async () => {
const retained = [userTurn('m1', 'earlier prompt', 1000), assistantTurn('m2', 'sure', 1100)]
await mount(retained)
// The read drops; the retained history stays visible and is still ours.
await update(retained, true)
rapidSend('run the tests', 'again')
const settled = [...retained, userTurn('m3', 'run the tests again', 5000)]
await update(settled)
expect(state?.pending).toEqual([])
expect(renderedPendingTexts(settled, state)).toEqual([])
})
// A send held here would sit as a live segment at the head of its run, so
// the cursor could never reach a later pair — the stuck bubble would take
// the whole feature down with it for the rest of the session.
// An image entry retires ONLY by binding its preview, so a supplied tail
// that excluded its own echo would leave the bubble — and the photo — stuck
// for the life of the session.
it('retires a photo whose image echo arrives with the read', async () => {
await mount([], true)
act(() => {
const origin = state?.captureSendOrigin('')
if (origin) {
state?.acceptSend(origin, '', ['file:///new.png'])
}
})
const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)]
await update(withEcho)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] })
})
it('leaves a later pair able to glue after the late read resolved the first send', async () => {
await mount([], true)
act(() => send('run the tests'))
const afterReconnect = [userTurn('m1', 'run the tests', 5000)]
await update(afterReconnect)
expect(state?.pending).toEqual([])
rapidSend('fix the', 'bug')
const glued = [...afterReconnect, userTurn('m2', 'fix the bug', 6000)]
await update(glued)
expect(state?.pending).toEqual([])
expect(renderedPendingTexts(glued, state)).toEqual([])
})
})
// A read that failed is not a boundary: `messages` is empty because the history
// is unknown, not because the conversation is. Without that distinction the
// empty list reads as "the transcript was empty", and any row the successful
// read finally brings can claim these sends.
describe('sends issued before any read settled', () => {
it('keeps both bubbles when the late read carries only an older glue-alike', async () => {
await mount([], false, false)
rapidSend('fix the', 'bug')
const olderIdentical = [
userTurn('m1', 'fix the bug', 1000),
assistantTurn('m2', 'fixed', 1100)
]
await update(olderIdentical)
expect(pendingTexts()).toEqual(['fix the', 'bug'])
expect(renderedPendingTexts(olderIdentical, state)).toEqual(['fix the', 'bug'])
})
it('still retires them once their own glued row lands', async () => {
await mount([], false, false)
rapidSend('fix the', 'bug')
const olderIdentical = [
userTurn('m1', 'fix the bug', 1000),
assistantTurn('m2', 'fixed', 1100)
]
await update(olderIdentical)
await update([...olderIdentical, userTurn('m3', 'fix the bug', 5000)])
expect(state?.pending).toEqual([])
})
})
})
@@ -36,7 +36,8 @@ describe('useMobileNativeChatDrafts launch draft', () => {
launchDraft = null,
launchDraftCreatedAt = null,
chatActive = true,
transcriptLoading = false
transcriptLoading = false,
transcriptSettled = !transcriptLoading
}: {
tabId: string
sessionId?: string | null
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
launchDraftCreatedAt?: number | null
chatActive?: boolean
transcriptLoading?: boolean
transcriptSettled?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
@@ -55,7 +57,8 @@ describe('useMobileNativeChatDrafts launch draft', () => {
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading
transcriptLoading,
transcriptSettled
})
return null
}
@@ -42,7 +42,8 @@ describe('useMobileNativeChatDrafts', () => {
messages = [],
launchDraft = null,
chatActive = true,
transcriptLoading = false
transcriptLoading = false,
transcriptSettled = !transcriptLoading
}: {
tabId: string
sessionId?: string | null
@@ -50,6 +51,7 @@ describe('useMobileNativeChatDrafts', () => {
launchDraft?: string | null
chatActive?: boolean
transcriptLoading?: boolean
transcriptSettled?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
@@ -59,7 +61,8 @@ describe('useMobileNativeChatDrafts', () => {
messages,
launchDraft,
chatActive,
transcriptLoading
transcriptLoading,
transcriptSettled
})
return null
}
@@ -1,16 +1,16 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
countImageSourceTurnsAfter,
countUserTextOccurrences,
findLandedImagePreviewEchoes,
findLandedUnconfirmedSends,
mergeLandedImagePreviewEchoes,
migrateImagePreviewMessageIds,
normalizeReconcileText,
normalizedUserText,
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement'
import {
appendMobileNativeChatPending,
combineMobileNativeChatPending,
@@ -47,6 +47,10 @@ export function useMobileNativeChatDrafts(args: {
* transcript still belongs to the previously active tab), so it cannot be
* trusted to decline or retire the seed. */
transcriptLoading?: boolean
/** `messages` is this session's own settled history — so an empty one really
* is an empty conversation, not a read that failed or never ran. Only then
* does a send's captured tail describe a real boundary. */
transcriptSettled: boolean
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
@@ -80,7 +84,8 @@ export function useMobileNativeChatDrafts(args: {
launchDraft,
launchDraftCreatedAt,
chatActive = true,
transcriptLoading
transcriptLoading,
transcriptSettled
} = args
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
@@ -138,10 +143,14 @@ export function useMobileNativeChatDrafts(args: {
pendingKey,
normalizedText,
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText),
baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null
baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null,
// Only a settled read makes this a boundary. Anything else — hydrating,
// or a read that failed — hands back an empty list that reads as "the
// conversation was empty", which lets any row claim this send later.
baselineResolved: transcriptSettled
}
},
[draftKey, pendingKey]
[draftKey, pendingKey, transcriptSettled]
)
// Why: over relay the send RPC can take seconds (or lose only its ack), and a
@@ -282,7 +291,16 @@ export function useMobileNativeChatDrafts(args: {
if (pending.length === 0) {
return
}
const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending)
// Only judge a send against a read known to be this session's. Note this
// does NOT give an image echo a boundary — the rebase deliberately leaves
// those on whatever they captured — so a caption-less photo sent before any
// read settled can still claim an older photo turn, exactly as it does on
// main. Fixing that needs a tail that excludes older image turns without
// excluding the send's own echo, which is a separate change.
const landedImagePreviews = findLandedImagePreviewEchoes(
messages,
pending.filter((item) => item.baselineResolved)
)
const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId))
if (landedImagePreviews.length > 0) {
setImagePreviewsBySession((previous) =>
@@ -291,28 +309,13 @@ export function useMobileNativeChatDrafts(args: {
}
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? []
const landedCounts = new Map<string, number>()
for (const message of messages) {
const text = normalizedUserText(message)
if (text) {
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
}
}
// Image-only source-turn counts stay stable across reruns and ignore paginated history.
const next = current.filter((item) => {
if (landedImagePendingIds.has(item.id)) {
return false
}
// Keep image echoes until their local preview reaches the authoritative message.
if (item.images?.length) {
return true
}
return item.text.trim() === ''
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) <
item.expectedOccurrence
: (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) < item.expectedOccurrence
})
if (next.length === current.length) {
// Rebase before retiring: a send captured before the history was known has
// to own a real boundary before any row can be judged against it.
const rebased = transcriptSettled
? rebaseMobileNativeChatPendingBaselines(messages, current)
: current
const next = retireLandedMobileNativeChatPending(messages, rebased, landedImagePendingIds)
if (next === current) {
return previous
}
if (next.length > 0) {
@@ -322,7 +325,7 @@ export function useMobileNativeChatDrafts(args: {
delete remaining[pendingKey]
return remaining
})
}, [messages, pending, pendingKey])
}, [messages, pending, pendingKey, transcriptSettled])
return {
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
@@ -4,6 +4,8 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Type-only, so it is erased before the `./mobile-native-chat-send` mock below applies.
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
const sendWithOutcome = vi.fn()
const clearInputWrite = vi.fn()
@@ -36,6 +38,9 @@ describe('useMobileNativeChatMessageSend', () => {
let renderer: ReactTestRenderer | null = null
let api: Send | null = null
const acceptSend = vi.fn()
const captureSendOrigin = vi.fn(() => ({ draftKey: 'k', pendingKey: 'p' }) as never)
const clearDraftForSend = vi.fn()
const restoreRejectedDraft = vi.fn()
const holdUnconfirmedSend = vi.fn()
const onCommandSend = vi.fn()
const commandSendRef = { current: onCommandSend }
@@ -55,10 +60,10 @@ describe('useMobileNativeChatMessageSend', () => {
deviceTokenRef: { current: 'device' },
agentRef,
commandSendRef,
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend: () => {},
restoreRejectedDraft: () => {},
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
holdUnconfirmedSend,
onSendError
@@ -71,10 +76,12 @@ describe('useMobileNativeChatMessageSend', () => {
}
const sentArgs = (): {
text?: string
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
} =>
sendWithOutcome.mock.calls[0]![0] as {
text?: string
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
}
@@ -89,6 +96,9 @@ describe('useMobileNativeChatMessageSend', () => {
typeCommandWithOutcome.mockReset()
typeCommandWithOutcome.mockResolvedValue('accepted')
acceptSend.mockReset()
captureSendOrigin.mockClear()
clearDraftForSend.mockReset()
restoreRejectedDraft.mockReset()
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
commandSendRef.current = onCommandSend
@@ -381,4 +391,53 @@ describe('useMobileNativeChatMessageSend', () => {
expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
releaseMobileNativeChatTerminalWrite('term')
})
// The host writes these bytes verbatim, so whitespace the match key drops must
// not reach the agent's input line and glue the next rapid send onto it (#14262).
it('trims trailing whitespace without changing leading prompt content', async () => {
mount(() => null)
await act(async () => {
await api!.send(' run the tests \n')
})
expect(sentArgs().text).toBe(' run the tests')
expect(captureSendOrigin).toHaveBeenCalledWith(' run the tests')
expect(acceptSend.mock.calls[0]![1]).toBe(' run the tests')
})
it('trims trailing whitespace on a question answer too', async () => {
mount(() => null)
await act(async () => {
await api!.answerQuestion('\n 1 ')
})
expect(sentArgs().text).toBe('\n 1')
})
// #14819: the trim is a wire concern only. A rejected send hands the composer
// back to the user, and it has to be the draft they typed, blank lines included.
it('restores the untrimmed draft when the send is rejected', async () => {
const draft = 'first line\n\nsecond line\n\n'
sendWithOutcome.mockResolvedValue('rejected')
mount(() => null)
await act(async () => {
await api!.send(draft)
})
expect(sentArgs().text).toBe('first line\n\nsecond line')
expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft)
expect(clearDraftForSend.mock.calls[0]![1]).toBe(draft)
})
it('restores the untrimmed draft when the launch-draft pre-clear is rejected', async () => {
const draft = 'ship it '
clearInputWrite.mockResolvedValue(false)
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send(draft)
})
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(restoreRejectedDraft.mock.calls[0]![1]).toBe(draft)
})
})
@@ -85,12 +85,17 @@ export function useMobileNativeChatMessageSend(args: {
const sendMessage = useCallback(
async (
text: string,
draftText: string,
images: string[] | undefined,
syncComposer: boolean,
recordControlSend: boolean,
sharedDeadline?: number
): Promise<MobileNativeChatSendOutcome> => {
// The host writes trailing whitespace verbatim onto the agent's input line,
// where it can glue the next rapid send onto this one (#14262). Only the
// bytes that go out are trimmed: `draftText` is what the user typed, and a
// rejected send has to put back exactly that (#14819).
const text = draftText.trimEnd()
const handle = handleRef.current
const origin = captureSendOrigin(text)
const agent = agentRef.current
@@ -124,7 +129,7 @@ export function useMobileNativeChatMessageSend(args: {
// round trip is visible, and a lost ack must not strand the sent prompt
// in the box. Only a definite rejection puts the text back.
if (syncComposer) {
clearDraftForSend(origin, text)
clearDraftForSend(origin, draftText)
}
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
// only one logical line. Size the clear to the text Orca injected, with
@@ -150,7 +155,7 @@ export function useMobileNativeChatMessageSend(args: {
})
if (!cleared) {
if (syncComposer) {
restoreRejectedDraft(origin, text)
restoreRejectedDraft(origin, draftText)
}
onSendError('Message not sent')
return 'rejected'
@@ -204,7 +209,7 @@ export function useMobileNativeChatMessageSend(args: {
}
if (outcome === 'rejected') {
if (syncComposer) {
restoreRejectedDraft(origin, text)
restoreRejectedDraft(origin, draftText)
}
onSendError('Message not sent')
return 'rejected'
@@ -10,8 +10,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
export function usePendingConnectionPath(hostId: string | undefined): MobileConnectionPath | null {
return useHostMetric(hostId, (context, id) => context.getPendingPath(id), null)
// Both inputs classifyConnection needs about a relay recovery, read from one
// subscription so a screen cannot render half of the escalation.
export function useRelayRecoveryStatus(hostId: string | undefined): {
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
} {
return useHostMetric(
hostId,
(context, id) => ({
pendingPath: context.getPendingPath(id),
pairingRejected: context.isPairingRejected(id)
}),
{ pendingPath: null, pairingRejected: false }
)
}
function useHostMetric<T>(
@@ -28,17 +28,20 @@ import {
useHostClient
} from './client-context'
import { useAllHostClients } from './use-all-host-clients'
import { useRelayRecoveryStatus } from './client-context-connection-metrics'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
emitPendingPath: (path: MobileConnectionPath | null) => void
emitPairingRejected: (rejected: boolean) => void
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
let state = initialState
let pendingPath: MobileConnectionPath | null = null
let pairingRejected = false
const listeners = new Set<(state: ConnectionState) => void>()
const pathListeners = new Set<() => void>()
const closeMock = vi.fn()
@@ -51,6 +54,7 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
getLastConnectedAt: () => null,
getActivePath: () => 'tailscale',
getPendingPath: () => pendingPath,
isPairingRejected: () => pairingRejected,
onConnectionPathChange: (listener: () => void) => {
pathListeners.add(listener)
return () => pathListeners.delete(listener)
@@ -73,6 +77,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
for (const listener of pathListeners) {
listener()
}
},
emitPairingRejected: (next) => {
pairingRejected = next
for (const listener of pathListeners) {
listener()
}
}
} as FakeClient
}
@@ -433,6 +443,35 @@ describe('useAllHostClients', () => {
act(() => renderer.unmount())
})
it('publishes a latched pairing rejection to the screens', async () => {
const client = makeFakeClient('reconnecting')
connectMock.mockReturnValue(client)
loadHostsMock.mockResolvedValue([HOST])
let status: { pendingPath: MobileConnectionPath | null; pairingRejected: boolean } | undefined
let renderer!: ReturnType<typeof create>
function Probe(): null {
// Why: a screen holds the host client and reads the metric hooks beside it.
useAllHostClients([HOST.id])
status = useRelayRecoveryStatus(HOST.id)
return null
}
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
act(() => client.emitPendingPath('relay'))
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: false })
// Why: the desktop refusing the credential is a status-only change — no
// transport state moves, so only the connection-path signal can carry it.
act(() => client.emitPairingRejected(true))
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: true })
act(() => renderer.unmount())
})
it('only opens the requested startup subset', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
connectMock.mockReturnValue(makeFakeClient('connected'))
@@ -14,6 +14,32 @@ describe('classifyConnection auth-failed verdict', () => {
})
})
describe('classifyConnection pairing-rejected verdict', () => {
it('outranks a pending Relay recovery that would otherwise read as progress', () => {
const verdict = classifyConnection({
state: 'disconnected',
reconnectAttempts: 1,
lastConnectedAt: 1_000,
pendingPath: 'relay',
pairingRejected: true,
nowMs: 1_000_000
})
expect(verdict.kind).toBe('auth-failed')
expect(verdictDisplayLabel(verdict)).toBe('Pairing invalid — re-pair with your desktop')
})
it('never overrides a live connection', () => {
const verdict = classifyConnection({
state: 'connected',
reconnectAttempts: 0,
lastConnectedAt: 999_000,
pairingRejected: true,
nowMs: 1_000_000
})
expect(verdict).toEqual({ kind: 'normal', label: 'Connected' })
})
})
describe('classifyConnection Tailscale hint', () => {
const base = {
state: 'reconnecting' as const,
+4 -1
View File
@@ -51,6 +51,9 @@ export function classifyConnection(args: {
// warning/unreachable verdicts. Callers without it get plain labels.
endpoint?: string | null
pendingPath?: MobileConnectionPath | null
// The desktop has repeatedly refused this device's relay credential — retrying
// cannot fix it, so it outranks any "still connecting" reading (STA-4681).
pairingRejected?: boolean
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
@@ -59,7 +62,7 @@ export function classifyConnection(args: {
// Why: auth-failed means the desktop no longer recognizes this pairing (e.g. it
// lost its device registry) — retrying can't fix it, only re-pairing can, so say so.
if (state === 'auth-failed') {
if (state === 'auth-failed' || (args.pairingRejected && state !== 'connected')) {
return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' }
}
@@ -86,10 +86,17 @@ export function createHostClientSelectors(
getActivePath: (hostId: string): MobileConnectionPath =>
clientActivePath(entries.get(hostId)?.client),
getPendingPath: (hostId: string): MobileConnectionPath | null =>
clientPendingPath(entries.get(hostId)?.client)
clientPendingPath(entries.get(hostId)?.client),
isPairingRejected: (hostId: string): boolean =>
clientPairingRejected(entries.get(hostId)?.client)
}
}
export function clientPairingRejected(client: RpcClient | undefined): boolean {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.isPairingRejected?.() ?? false
}
export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
if (typeof logical?.getActivePath !== 'function') {
@@ -3,6 +3,8 @@ import type { MobileConnectionPath } from './stable-logical-rpc-client'
export class LogicalClientConnectionPath {
private migration: MobileConnectionPath | null = null
private recovery: MobileConnectionPath | null = null
private recoveryAttempt = 0
private pairingRejected = false
private readonly listeners = new Set<() => void>()
constructor(private readonly isConnected: () => boolean) {}
@@ -17,14 +19,44 @@ export class LogicalClientConnectionPath {
})
}
reconnectAttempt(activeAttempt: number): number {
return this.pending() === 'relay'
? Math.max(activeAttempt, this.recoveryAttempt)
: activeAttempt
}
isPairingRejected(): boolean {
return this.pairingRejected
}
setPairingRejected(rejected: boolean): void {
this.update(() => {
this.pairingRejected = rejected
})
}
clearAfterConnected(): void {
this.migration = null
this.recovery = null
this.recoveryAttempt = 0
// Why: an authenticated session is the desktop accepting this device.
this.pairingRejected = false
}
setRecovery(path: MobileConnectionPath | null): void {
setRecovery(path: MobileConnectionPath | null, attempt?: number): void {
this.update(() => {
this.recovery = path
if (path === null) {
this.recoveryAttempt = 0
} else if (attempt !== undefined) {
this.recoveryAttempt = Math.max(0, Math.trunc(attempt))
}
})
}
setRecoveryAttempt(attempt: number): void {
this.update(() => {
this.recoveryAttempt = Math.max(0, Math.trunc(attempt))
})
}
@@ -34,9 +66,15 @@ export class LogicalClientConnectionPath {
}
private update(apply: () => void): void {
const previous = this.pending()
const previousPath = this.pending()
const previousAttempt = this.reconnectAttempt(0)
const previousRejected = this.pairingRejected
apply()
if (previous === this.pending()) {
if (
previousPath === this.pending() &&
previousAttempt === this.reconnectAttempt(0) &&
previousRejected === this.pairingRejected
) {
return
}
for (const listener of this.listeners) {
@@ -66,6 +66,7 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private recoveryPath: MobileConnectionPath | null = null
private recoveryAttempt = 0
private generation = 1
private readonly pathListeners = new Set<() => void>()
@@ -92,18 +93,53 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
}
this.path = path
this.recoveryPath = null
this.recoveryAttempt = 0
this.generation += 1
// Connected-state publication carries the migration cleanup.
this.publishState('connected')
}
)
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getReconnectAttempt = () => (this.getPendingPath() === 'relay' ? this.recoveryAttempt : 0)
getActivePath = () => this.path
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
setRecoveryPath = vi.fn((path: MobileConnectionPath | null, attempt?: number) => {
const previous = this.getPendingPath()
const previousAttempt = this.getReconnectAttempt()
this.recoveryPath = path
if (previous !== this.getPendingPath()) {
if (path === null) {
this.recoveryAttempt = 0
} else if (attempt !== undefined) {
this.recoveryAttempt = attempt
}
if (previous !== this.getPendingPath() || previousAttempt !== this.getReconnectAttempt()) {
for (const listener of this.pathListeners) {
listener()
}
}
})
private pairingRejected = false
setPairingRejected = vi.fn((rejected: boolean) => {
if (this.pairingRejected === rejected) {
return
}
this.pairingRejected = rejected
for (const listener of this.pathListeners) {
listener()
}
})
isPairingRejected = () => this.pairingRejected
// Mirrors LogicalClientConnectionPath.clearAfterConnected.
publishState(state: ConnectionState): void {
if (state === 'connected') {
this.pairingRejected = false
}
super.publishState(state)
}
setRecoveryAttempt = vi.fn((attempt: number) => {
const previous = this.getReconnectAttempt()
this.recoveryAttempt = attempt
if (previous !== this.getReconnectAttempt()) {
for (const listener of this.pathListeners) {
listener()
}
@@ -58,6 +58,7 @@ export class MobileEndpointSupervisor {
})
this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog)
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
this.relayReconnect.reportRecoveryTo(logical)
this.nudgeRouter = new MobileEndpointNudgeRouter({
logical,
controller: this.relayReconnect,
@@ -179,11 +180,7 @@ export class MobileEndpointSupervisor {
} else {
// Why: background phones must not hold billed relay data splices.
this.relayReconnect.suspendActiveRelay(this.logical)
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
this.clearScheduledRecovery()
}
}
@@ -193,6 +190,10 @@ export class MobileEndpointSupervisor {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
this.clearScheduledRecovery()
}
private clearScheduledRecovery(): void {
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
@@ -268,7 +269,7 @@ export class MobileEndpointSupervisor {
if (this.stopped || !this.foreground || !recoveryNeeded) {
return
}
this.logical.setRecoveryPath('relay')
this.logical.setRecoveryPath('relay', this.relayReconnect.getFailureCount())
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
if (dialed.outcome === 'established') {
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { classifyConnection } from './connection-health'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import {
dependencies,
FakeRelaySession,
FakeSession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { createStableLogicalRpcClient } from './stable-logical-rpc-client'
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('continuous Relay outage escalation', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-17T04:00:00Z'))
})
afterEach(() => vi.useRealTimers())
it('publishes supervisor recovery attempts and escalates the Relay verdict', async () => {
const failure = new RelayOuterError(4408)
const activeRelay = new FakeRelaySession('connected', failure)
activeRelay.getLastConnectedAt = () => Date.now() - 120_000
const logical = createStableLogicalRpcClient(new FakeSession('disconnected'), 'tailscale')
const publishedAttempts: number[] = []
logical.onConnectionPathChange(() => publishedAttempts.push(logical.getReconnectAttempt()))
const openRelay = vi.fn(() => {
const session = new FakeRelaySession('connecting', failure)
setTimeout(() => session.publishState('disconnected'), 0)
return session
})
openRelay.mockReturnValueOnce(activeRelay)
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay,
randomBytes: () => new Uint8Array([0, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(3_000)
expect(openRelay).toHaveBeenCalledTimes(12)
expect(logical.getPendingPath()).toBe('relay')
expect(logical.getReconnectAttempt()).toBe(12)
expect(publishedAttempts.at(-1)).toBe(12)
expect(
classifyConnection({
state: logical.getState(),
reconnectAttempts: logical.getReconnectAttempt(),
lastConnectedAt: logical.getLastConnectedAt(),
pendingPath: logical.getPendingPath()
})
).toMatchObject({ kind: 'unreachable', label: "Can't connect via Relay" })
supervisor.stop()
logical.close()
})
})
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { classifyConnection, type ConnectionVerdict } from './connection-health'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import {
dependencies,
FakeRelaySession,
FakeSession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
createStableLogicalRpcClient,
type StableLogicalRpcClient
} from './stable-logical-rpc-client'
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) }))
// Why: 12+ gated reprobe ticks (45s escalating to the 675s floored ceiling) — long
// past any transient post-pairing rejection, which is the window under test.
const FOUR_HOURS_MS = 4 * 60 * 60_000
function verdict(logical: StableLogicalRpcClient): ConnectionVerdict {
return classifyConnection({
state: logical.getState(),
reconnectAttempts: logical.getReconnectAttempt(),
lastConnectedAt: logical.getLastConnectedAt(),
pendingPath: logical.getPendingPath(),
pairingRejected: logical.isPairingRejected()
})
}
describe('continuous Relay pairing-rejection escalation', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-18T04:00:00Z'))
})
afterEach(() => vi.useRealTimers())
// Establishes Relay through the supervisor, then fails every replacement dial.
function harness(replacementFailure: Error, replacementsAfter = Number.POSITIVE_INFINITY) {
const activeRelay = new FakeRelaySession('connected', new RelayOuterError(4408))
activeRelay.getLastConnectedAt = () => Date.now() - 120_000
const logical = createStableLogicalRpcClient(new FakeSession('disconnected'), 'tailscale')
let replacements = 0
const openRelay = vi.fn(() => {
replacements += 1
if (replacements > replacementsAfter) {
return new FakeRelaySession('connected')
}
const session = new FakeRelaySession('connecting', replacementFailure)
setTimeout(() => session.publishState('auth-failed'), 0)
return session
})
openRelay.mockImplementationOnce(() => activeRelay)
const deps = dependencies({
openDirect: vi.fn(() => new FakeSession('disconnected')),
openRelay,
randomBytes: () => new Uint8Array([0, 0]),
onLog: () => {}
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
return { activeRelay, logical, openRelay, supervisor }
}
it('escalates a persistent rejection to re-pair instead of "Connecting via Relay…"', async () => {
const { activeRelay, logical, openRelay, supervisor } = harness(
new MobileE2EEAuthenticationError()
)
await supervisor.start()
expect(logical.getActivePath()).toBe('relay')
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
// Pre-fix, the gate suppressed the failure count so this stalled on
// "Connecting via Relay…" for every one of these dials (STA-4681).
expect(openRelay.mock.calls.length).toBeGreaterThan(3)
expect(logical.isPairingRejected()).toBe(true)
expect(verdict(logical)).toMatchObject({
kind: 'auth-failed',
label: 'Pairing invalid — re-pair with your desktop'
})
supervisor.stop()
logical.close()
})
it('escalates a relay-only phone rejected from its very first dial', async () => {
const { logical, openRelay, supervisor } = harness(new MobileE2EEAuthenticationError())
openRelay.mockReset()
openRelay.mockImplementation(() => {
const session = new FakeRelaySession('connecting', new MobileE2EEAuthenticationError())
setTimeout(() => session.publishState('auth-failed'), 0)
return session
})
// Why: the first dial fails, so start() only settles once fake timers run.
const started = supervisor.start()
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
await started
expect(openRelay.mock.calls.length).toBeGreaterThan(3)
expect(verdict(logical)).toMatchObject({ kind: 'auth-failed' })
supervisor.stop()
logical.close()
})
it('holds the verdict through a background/foreground cycle', async () => {
const { activeRelay, logical, supervisor } = harness(new MobileE2EEAuthenticationError())
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
expect(logical.isPairingRejected()).toBe(true)
// Why: an app resume resets the retry cadence, but it is not evidence the
// desktop changed its mind — pre-fix this walked the count backwards.
supervisor.setForeground(false)
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(logical.isPairingRejected()).toBe(true)
expect(verdict(logical)).toMatchObject({ kind: 'auth-failed' })
supervisor.stop()
logical.close()
})
it('never latches when the desktop accepts before the budget is spent', async () => {
const { activeRelay, logical, supervisor } = harness(new MobileE2EEAuthenticationError(), 2)
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
// Two transient rejections while the desktop commits credentials, then success.
expect(logical.isPairingRejected()).toBe(false)
expect(logical.getActivePath()).toBe('relay')
expect(verdict(logical)).toMatchObject({ kind: 'normal' })
supervisor.stop()
logical.close()
})
it('escalates inside a bounded window, and never on a brief blip', async () => {
// Why: the budget is only meaningful with a cadence. Three rejections cost one fast
// transport retry plus two gated reprobes (60s and 120s bases, jittered 0.75-1.25x),
// so the floor is ~2m15s and the ceiling ~3m45s in production.
const { activeRelay, logical, supervisor } = harness(new MobileE2EEAuthenticationError())
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.isPairingRejected()).toBe(false)
await vi.advanceTimersByTimeAsync(4 * 60_000)
expect(logical.isPairingRejected()).toBe(true)
supervisor.stop()
logical.close()
})
it('clears the verdict end to end when the desktop accepts the pairing again', async () => {
// Why: the unit tests clear the latch through controller methods directly. If the
// supervisor's success wiring regressed, a re-paired phone would stay on "re-pair"
// forever with every other assertion still green.
const { activeRelay, logical, supervisor } = harness(new MobileE2EEAuthenticationError(), 4)
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(4 * 60_000)
expect(logical.isPairingRejected()).toBe(true)
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
expect(logical.isPairingRejected()).toBe(false)
expect(logical.getActivePath()).toBe('relay')
expect(verdict(logical)).toMatchObject({ kind: 'normal' })
supervisor.stop()
logical.close()
})
it('never reports re-pair for a rejected relay credential', async () => {
// Why: a 4401 close enters the fresh-credential gate, not the pairing-rejection
// path. It is repaired by credential rotation, so it must not accuse the pairing.
const { activeRelay, logical, supervisor } = harness(new RelayOuterError(4401))
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(FOUR_HOURS_MS)
expect(logical.isPairingRejected()).toBe(false)
expect(verdict(logical).kind).not.toBe('auth-failed')
supervisor.stop()
logical.close()
})
it('leaves a plain transport outage on the Relay-unreachable verdict', async () => {
const { activeRelay, logical, supervisor } = harness(new RelayOuterError(4408))
await supervisor.start()
activeRelay.publishState('disconnected')
await vi.advanceTimersByTimeAsync(3_000)
expect(logical.isPairingRejected()).toBe(false)
expect(verdict(logical)).toMatchObject({
kind: 'unreachable',
label: "Can't connect via Relay"
})
supervisor.stop()
logical.close()
})
})
@@ -49,6 +49,87 @@ describe('relay reconnect controller', () => {
expect(onRetry).not.toHaveBeenCalled()
})
it('publishes consecutive failures and the direct-connection reset', () => {
const published: number[] = []
const reconnect = createController(vi.fn(), (count) => published.push(count))
reconnect.registerFailure(new RelayOuterError(4408), false)
reconnect.registerFailure(new RelayOuterError(4408), false)
reconnect.resetForDirectConnection()
expect(published).toEqual([0, 1, 2, 0])
})
it('latches pairing-rejected only after the transient-rejection budget is spent', () => {
const rejected: boolean[] = []
const reconnect = createController(vi.fn(), undefined, (value) => rejected.push(value))
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
expect(rejected).toEqual([false])
// Why: the gate is held by now, so this rejection takes registerFailure's early
// return — pre-fix it was invisible and the latch never fired (STA-4681).
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
expect(rejected).toEqual([false, true])
})
it('keeps the pairing-rejected latch across a gate lift and app resume', () => {
const logical = { getState: () => 'disconnected' } as never
const rejected: boolean[] = []
const reconnect = createController(vi.fn(), undefined, (value) => rejected.push(value))
for (let attempt = 0; attempt < 3; attempt++) {
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
}
// Why: a resume re-arms the retry cadence but is not the desktop changing its
// mind — only an authenticated session is.
reconnect.handleForeground(logical, false)
expect(rejected).toEqual([false, true])
})
it('never latches pairing-rejected while an authenticated relay is still live', () => {
// Why: a live authenticated session is the desktop currently accepting this
// device. A replacement dial that trips E2EE (relay identity mismatch, or the
// transient window while the desktop commits a rotation) is not revocation, and
// banking it would fire a false re-pair alarm the moment that session drops.
const rejected: boolean[] = []
const reconnect = createController(vi.fn(), undefined, (value) => rejected.push(value))
reconnect.setActiveSession({ getFailure: () => null } as unknown as MobileRelayRpcSession)
for (let attempt = 0; attempt < 3; attempt++) {
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
}
expect(rejected).toEqual([false])
})
it('clears the pairing-rejected latch once the desktop authenticates the device', () => {
const rejected: boolean[] = []
const reconnect = createController(vi.fn(), undefined, (value) => rejected.push(value))
for (let attempt = 0; attempt < 3; attempt++) {
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
}
reconnect.setActiveSession({ getFailure: () => null } as unknown as MobileRelayRpcSession)
expect(rejected).toEqual([false, true, false])
})
it('clears the pairing-rejected latch when direct connectivity proves the pairing', () => {
const rejected: boolean[] = []
const reconnect = createController(vi.fn(), undefined, (value) => rejected.push(value))
for (let attempt = 0; attempt < 3; attempt++) {
reconnect.registerFailure(new MobileE2EEAuthenticationError(), false)
}
// Why: direct auth resolves the same desktop device registry.
reconnect.resetForDirectConnection()
expect(rejected).toEqual([false, true, false])
})
it('reprobes slowly after rejected E2EE authentication instead of parking forever', () => {
// Why: on a relay-only phone a permanent gate is a permanent outage — the
// desktop can commit pairing credentials moments after the first rejection.
@@ -267,8 +348,12 @@ describe('relay reconnect controller', () => {
})
})
function createController(onRetry: () => void): RelayReconnectController {
return new RelayReconnectController(
function createController(
onRetry: () => void,
reportFailureCount: (count: number) => void = () => {},
reportPairingRejected: (rejected: boolean) => void = () => {}
): RelayReconnectController {
const controller = new RelayReconnectController(
{
now: Date.now,
randomBytes: () => new Uint8Array([128, 0]),
@@ -277,4 +362,9 @@ function createController(onRetry: () => void): RelayReconnectController {
},
onRetry
)
controller.reportRecoveryTo({
setRecoveryAttempt: reportFailureCount,
setPairingRejected: reportPairingRejected
} as unknown as StableLogicalRpcClient)
return controller
}
@@ -8,6 +8,8 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
import { RelayCredentialEligibility } from './relay-credential-eligibility'
import { RelayPairingRejectionLatch } from './relay-pairing-rejection-latch'
import { RelayRecoveryFailureCount } from './relay-recovery-failure-count'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, ForegroundNudgeReason } from './types'
@@ -23,7 +25,8 @@ export type RelayReconnectDependencies = {
type RecoveryGate = 'external-signal' | 'fresh-credential'
export class RelayReconnectController {
private consecutiveFailures = 0
private readonly failureCount = new RelayRecoveryFailureCount(RELAY_STABLE_CONNECTION_MS)
private readonly pairingRejection = new RelayPairingRejectionLatch()
private activeRelayConnectedAt: number | null = null
private nextAttemptAt = 0
private timer: ReturnType<typeof setTimeout> | null = null
@@ -42,6 +45,15 @@ export class RelayReconnectController {
this.credentials = new RelayCredentialEligibility(dependencies.now)
}
getFailureCount(): number {
return this.failureCount.current()
}
reportRecoveryTo(logical: StableLogicalRpcClient): void {
this.failureCount.reportTo(logical.setRecoveryAttempt)
this.pairingRejection.reportTo(logical.setPairingRejected)
}
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
if (!wasForeground) {
// Why: an app resume is a fresh signal, unlike repeated network-flap
@@ -101,6 +113,9 @@ export class RelayReconnectController {
}
setActiveSession(session: MobileRelayRpcSession): void {
// Why: an authenticated relay is the desktop accepting this device — the only
// evidence that outranks a rejection streak.
this.pairingRejection.clear()
this.activeSession = session
this.activeRelayConnectedAt = this.dependencies.now()
this.nextAttemptAt = 0
@@ -112,11 +127,14 @@ export class RelayReconnectController {
this.recoveryGate === 'fresh-credential' || this.credentials.hasRejected()
this.activeSession = null
this.activeRelayConnectedAt = null
// Why: direct auth resolves the same desktop device registry, so a live direct
// session disproves revocation even though relay is still gated.
this.pairingRejection.clear()
if (needsCredentialRefresh) {
// Why: the rejected credential stays unusable until its replacement is
// durable. No reprobe timer here — direct is live, rotation over it
// clears the gate, and any later state failure re-arms via shouldDefer.
this.consecutiveFailures = 0
this.failureCount.reset()
this.nextAttemptAt = 0
this.recoveryGate = 'fresh-credential'
this.gateReprobePending = false
@@ -130,6 +148,7 @@ export class RelayReconnectController {
completeCredentialRefresh(): void {
if (this.recoveryGate === 'fresh-credential') {
this.pairingRejection.clear()
this.credentials.clearRejected()
this.reset()
}
@@ -146,9 +165,7 @@ export class RelayReconnectController {
): T[] {
const eligible = this.credentials.eligible(...credentials)
if (eligible.length === 0 && this.credentials.hasRejected()) {
this.recoveryGate = 'fresh-credential'
this.clearTimer()
this.scheduleGateReprobe()
this.holdGate(true, 'fresh-credential')
}
return eligible
}
@@ -157,16 +174,13 @@ export class RelayReconnectController {
// alive so a later durable write can recover.
armCredentialReprobe(): void {
if (this.credentials.hasRejected()) {
this.recoveryGate = 'fresh-credential'
this.clearTimer()
this.scheduleGateReprobe()
this.holdGate(true, 'fresh-credential')
return
}
if (this.recoveryGate) {
// Why: under a held gate the tick must mint its pass token — a plain
// cooldown tick bounces off shouldDefer and doubles the effective cadence.
this.clearTimer()
this.scheduleGateReprobe()
this.holdGate(true)
return
}
// Why: a merely missing or expired bundle must not enter the credential
@@ -221,6 +235,12 @@ export class RelayReconnectController {
}
registerFailure(error: Error | null, scheduleRetry = true): void {
// Why: the gated early return below skips every later failure, so a revoked
// pairing must bank its evidence first or the UI waits forever (STA-4681). A live
// authenticated relay is the desktop accepting this device right now, so a failed
// replacement dial is not revocation — banking it would fire a false re-pair alarm
// the moment that healthy session drops for an unrelated transport error.
this.pairingRejection.record(this.activeSession ? null : error)
const code = error instanceof RelayOuterError ? error.code : null
const recovery =
code != null && isMobileRelayCloseCode(code)
@@ -232,47 +252,29 @@ export class RelayReconnectController {
) {
// Why: a failed reprobe stays gated, but the slow cadence must keep going
// — unless the supervisor is backgrounded/stopped; resume re-arms it.
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
this.holdGate(scheduleRetry)
return
}
const now = this.dependencies.now()
if (
this.activeRelayConnectedAt != null &&
now - this.activeRelayConnectedAt >= RELAY_STABLE_CONNECTION_MS
) {
this.consecutiveFailures = 0
}
// Why: elapsed time inside a slow failed dial is not evidence of recovery;
// only an authenticated relay that survived the stability window resets the streak.
const failureCount = this.failureCount.recordAfterConnection(this.activeRelayConnectedAt, now)
// Why: only a stable authenticated Relay resets the failure streak.
this.activeRelayConnectedAt = null
this.consecutiveFailures += 1
const delay =
recovery?.kind === 'retry-after-host-offline'
? this.delays.hostOfflineDelayMs()
: this.delays.transportDelayMs(this.consecutiveFailures)
: this.delays.transportDelayMs(failureCount)
this.nextAttemptAt = now + delay
if (error instanceof MobileE2EEAuthenticationError) {
// Why: an E2EE rejection is usually pairing revocation, but it also fires
// transiently right after pairing while the desktop commits credentials —
// reprobe slowly instead of waiting forever for a UI nudge.
this.recoveryGate = 'external-signal'
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
this.holdGate(scheduleRetry, 'external-signal')
return
}
if (recovery?.kind === 'disable-relay-credential') {
// Why: never redial a rejected credential fast, but keep a slow reprobe
// alive — the caller re-reads durable state before each gated attempt.
this.recoveryGate = 'fresh-credential'
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
this.holdGate(scheduleRetry, 'fresh-credential')
return
}
if (recovery?.kind === 'retry-after-host-offline') {
@@ -304,7 +306,7 @@ export class RelayReconnectController {
}
reset(): void {
this.consecutiveFailures = 0
this.failureCount.reset()
this.activeRelayConnectedAt = null
this.nextAttemptAt = 0
this.liftGate()
@@ -357,6 +359,16 @@ export class RelayReconnectController {
}, delay)
}
// Holds (or enters) a gate: only the slow reprobe cadence survives, and a
// backgrounded or stopped supervisor gets no timer at all until it resumes.
private holdGate(scheduleRetry: boolean, gate = this.recoveryGate): void {
this.recoveryGate = gate
this.clearTimer()
if (scheduleRetry) {
this.scheduleGateReprobe()
}
}
// Why: clearing a gate must also drop its timer, pending tick, and cadence —
// an orphaned reprobe timer would otherwise swallow the next fast backoff.
private liftGate(): void {
@@ -91,6 +91,7 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private recoveryPath: MobileConnectionPath | null = null
private recoveryAttempt = 0
private generation = 1
private readonly pathListeners = new Set<() => void>()
@@ -106,17 +107,52 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
}
this.path = path
this.recoveryPath = null
this.recoveryAttempt = 0
this.generation += 1
// Connected-state publication carries the migration cleanup.
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getReconnectAttempt = () => (this.getPendingPath() === 'relay' ? this.recoveryAttempt : 0)
getActivePath = () => this.path
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
setRecoveryPath = vi.fn((path: MobileConnectionPath | null, attempt?: number) => {
const previous = this.getPendingPath()
const previousAttempt = this.getReconnectAttempt()
this.recoveryPath = path
if (previous !== this.getPendingPath()) {
if (path === null) {
this.recoveryAttempt = 0
} else if (attempt !== undefined) {
this.recoveryAttempt = attempt
}
if (previous !== this.getPendingPath() || previousAttempt !== this.getReconnectAttempt()) {
for (const listener of this.pathListeners) {
listener()
}
}
})
private pairingRejected = false
setPairingRejected = vi.fn((rejected: boolean) => {
if (this.pairingRejected === rejected) {
return
}
this.pairingRejected = rejected
for (const listener of this.pathListeners) {
listener()
}
})
isPairingRejected = () => this.pairingRejected
// Mirrors LogicalClientConnectionPath.clearAfterConnected.
publishState(state: ConnectionState): void {
if (state === 'connected') {
this.pairingRejected = false
}
super.publishState(state)
}
setRecoveryAttempt = vi.fn((attempt: number) => {
const previous = this.getReconnectAttempt()
this.recoveryAttempt = attempt
if (previous !== this.getReconnectAttempt()) {
for (const listener of this.pathListeners) {
listener()
}
@@ -266,7 +302,7 @@ describe('relay runtime recovery without direct connectivity', () => {
await vi.advanceTimersByTimeAsync(60_000)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay')
expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay', 0)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
@@ -0,0 +1,43 @@
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
// Why: one E2EE rejection is not proof the pairing is dead — the desktop fires it
// transiently while it commits credentials right after pairing. Mirrors rpc-client's
// AUTH_RETRY_BUDGET so both transports latch "re-pair" on the same weight of evidence.
const RELAY_PAIRING_REJECTION_BUDGET = 3
// Consecutive desktop rejections of this device's relay credential. Only an
// authenticated connection clears it — a gate lift or app resume must not, or a
// revoked phone re-enters the reassuring "Connecting via Relay…" label forever.
export class RelayPairingRejectionLatch {
private count = 0
private reporter: ((rejected: boolean) => void) | null = null
rejected(): boolean {
return this.count >= RELAY_PAIRING_REJECTION_BUDGET
}
reportTo(reporter: (rejected: boolean) => void): void {
this.reporter = reporter
reporter(this.rejected())
}
// Only an E2EE rejection is evidence; every other relay failure leaves the streak
// untouched so a flaky network cannot masquerade as a revoked pairing.
record(error: Error | null): void {
if (error instanceof MobileE2EEAuthenticationError) {
this.update(Math.min(this.count + 1, RELAY_PAIRING_REJECTION_BUDGET))
}
}
clear(): void {
this.update(0)
}
private update(count: number): void {
const wasRejected = this.rejected()
this.count = count
if (wasRejected !== this.rejected()) {
this.reporter?.(this.rejected())
}
}
}
@@ -0,0 +1,36 @@
export class RelayRecoveryFailureCount {
private count = 0
private reporter: ((count: number) => void) | null = null
constructor(private readonly stableConnectionMs: number) {}
current(): number {
return this.count
}
reportTo(reporter: (count: number) => void): void {
this.reporter = reporter
reporter(this.count)
}
record(resetFirst: boolean): number {
this.update((resetFirst ? 0 : this.count) + 1)
return this.count
}
recordAfterConnection(connectedAt: number | null, now: number): number {
return this.record(connectedAt != null && now - connectedAt >= this.stableConnectionMs)
}
reset(): void {
this.update(0)
}
private update(count: number): void {
if (this.count === count) {
return
}
this.count = count
this.reporter?.(count)
}
}
@@ -22,6 +22,7 @@ export type RpcClientContextValue = {
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
getPendingPath: (hostId: string) => MobileConnectionPath | null
isPairingRejected: (hostId: string) => boolean
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
@@ -302,6 +302,38 @@ describe('stable logical RPC client', () => {
expect(paths).toEqual(['relay', null])
})
it('publishes supervisor Relay attempts without replacing the physical retry count', () => {
const direct = new FakeSession('reconnecting')
direct.getReconnectAttempt = () => 5
const client = createStableLogicalRpcClient(direct, 'tailscale')
const attempts: number[] = []
client.onConnectionPathChange(() => attempts.push(client.getReconnectAttempt()))
client.setRecoveryPath('relay', 3)
expect(client.getReconnectAttempt()).toBe(5)
client.setRecoveryAttempt(7)
expect(client.getReconnectAttempt()).toBe(7)
expect(attempts).toEqual([5, 7])
client.setRecoveryPath(null)
expect(client.getReconnectAttempt()).toBe(5)
expect(attempts).toEqual([5, 7, 5])
})
it('notifies connection-path subscribers when the pairing-rejected latch flips', () => {
const direct = new FakeSession('reconnecting')
const client = createStableLogicalRpcClient(direct, 'tailscale')
const rejected: boolean[] = []
client.onConnectionPathChange(() => rejected.push(client.isPairingRejected()))
client.setPairingRejected(true)
client.setPairingRejected(true)
client.setPairingRejected(false)
expect(rejected).toEqual([true, false])
})
it('does not revive a stale recovery path after a connection later drops', () => {
const direct = new FakeSession('reconnecting')
const client = createStableLogicalRpcClient(direct, 'tailscale')
@@ -50,7 +50,12 @@ export type StableLogicalRpcClient = RpcClient & {
getActivePath(): MobileConnectionPath
// The path the user is waiting on while migration or scheduled recovery is active.
getPendingPath(): MobileConnectionPath | null
setRecoveryPath(path: MobileConnectionPath | null): void
setRecoveryPath(path: MobileConnectionPath | null, attempt?: number): void
setRecoveryAttempt(attempt: number): void
// Latched when the desktop has repeatedly refused this device's relay credential.
setPairingRejected(rejected: boolean): void
isPairingRejected(): boolean
// Recovery attempts share this signal so status-only changes rerender.
onConnectionPathChange(listener: () => void): () => void
getGeneration(): number
}
@@ -153,7 +158,7 @@ export function createStableLogicalRpcClient(
},
getState: () => state,
getReconnectAttempt: () => activeSession.getReconnectAttempt(),
getReconnectAttempt: () => connectionPath.reconnectAttempt(activeSession.getReconnectAttempt()),
getLastConnectedAt: () => activeSession.getLastConnectedAt(),
onStateChange(listener) {
stateListeners.add(listener)
@@ -272,7 +277,10 @@ export function createStableLogicalRpcClient(
// Why: a previous session that recovers mid-dial makes the pending path a lie —
// once we're connected the user is no longer waiting on anything.
getPendingPath: () => connectionPath.pending(),
setRecoveryPath: (path) => connectionPath.setRecovery(path),
setRecoveryPath: (path, attempt) => connectionPath.setRecovery(path, attempt),
setRecoveryAttempt: (attempt) => connectionPath.setRecoveryAttempt(attempt),
setPairingRejected: (rejected) => connectionPath.setPairingRejected(rejected),
isPairingRejected: () => connectionPath.isPairingRejected(),
onConnectionPathChange: (listener) => connectionPath.subscribe(listener),
getGeneration: () => generation
}
+3 -1
View File
@@ -137,6 +137,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
state: ConnectionState
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
@@ -146,7 +147,8 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
client,
state: ctx.getState(hostId),
path: ctx.getActivePath(hostId),
pendingPath: ctx.getPendingPath(hostId)
pendingPath: ctx.getPendingPath(hostId),
pairingRejected: ctx.isPairingRejected(hostId)
}
]
: []
@@ -810,14 +810,31 @@ final class Provider {
guard isSettable(record.element, kAXValueAttribute as String) else {
throw ProviderError.coded("value_not_settable", "element \(record.index) is not settable")
}
let result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, expected as CFString)
let current = rawAttributeValue(record.element, kAXValueAttribute as String)
let coercion = AttributeValueCoercion(existingValue: current, requested: expected)
let result: AXError
switch coercion.writeValue {
case .string:
result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, expected as CFString)
case let .integer(value):
result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, NSNumber(value: value))
case let .double(value):
result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, NSNumber(value: value))
case let .boolean(value):
result = AXUIElementSetAttributeValue(record.element, kAXValueAttribute as CFString, value ? kCFBooleanTrue : kCFBooleanFalse)
}
guard result == .success else {
throw ProviderError.coded("accessibility_error", "AXUIElementSetAttributeValue failed with \(result.rawValue)")
}
let actual = rawStringAttribute(record.element, kAXValueAttribute as String)
let verification = actual == expected
? verifiedAction(property: "value", expected: expected, actualPreview: actual)
: unverifiedAction(reason: actual == nil ? "provider_unavailable" : "value_mismatch", expected: expected, actualPreview: actual)
let verification: [String: Any]
switch coercion.compare(readback: rawAttributeValue(record.element, kAXValueAttribute as String)) {
case let .match(actualPreview):
verification = verifiedAction(property: "value", expected: expected, actualPreview: actualPreview)
case let .mismatch(actualPreview):
verification = unverifiedAction(reason: "value_mismatch", expected: expected, actualPreview: actualPreview)
case .unsupported:
verification = unverifiedAction(reason: "readback_unsupported", expected: expected)
}
return actionMetadata(path: "accessibility", actionName: "AXSetValue", verification: verification)
}
@@ -1160,10 +1177,29 @@ private func isTargetWindowFocused(_ snapshot: Snapshot) -> Bool {
return !intersection.isNull && intersection.area >= min(frame.area, snapshot.windowBounds.area) * 0.75
}
private enum AXElementProbe {
case value(AXUIElement)
case absent
case unavailable
}
private func copyElementProbe(_ element: AXUIElement, _ attribute: String) -> AXElementProbe {
var value: CFTypeRef?
switch AXUIElementCopyAttributeValue(element, attribute as CFString, &value) {
case .success:
guard let value else { return .unavailable }
return .value(value as! AXUIElement)
case .noValue:
return .absent
default:
return .unavailable
}
}
private func currentSyntheticClickRecipient(
snapshot: Snapshot,
point: CGPoint
) -> SyntheticMouseClickDelivery.Recipient? {
) -> SyntheticMouseClickDelivery.RecipientObservation {
let target = syntheticClickRecipient(pid: snapshot.app.pid, windowId: snapshot.windowId)
var cachedTargetCandidates: [WindowCandidate]?
func targetCandidates() -> [WindowCandidate] {
@@ -1172,39 +1208,60 @@ private func currentSyntheticClickRecipient(
cachedTargetCandidates = candidates
return candidates
}
if let focused = focusedSyntheticClickRecipient(
switch focusedSyntheticClickRecipient(
targetPID: snapshot.app.pid,
targetCandidates: targetCandidates
) {
guard focused == target else { return focused }
} else {
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == snapshot.app.pid else {
return nil
case let .focused(focused):
guard focused == target else { return .focused(focused) }
switch hitTestSyntheticClickRecipient(
at: point,
targetPID: snapshot.app.pid,
targetCandidates: targetCandidates
) {
case let .focused(recipient):
return .focused(recipient)
case .dismissed, .unavailable:
return .unavailable
}
case .dismissed:
return .dismissed
case .unavailable:
return .unavailable
}
return hitTestSyntheticClickRecipient(
at: point,
targetPID: snapshot.app.pid,
targetCandidates: targetCandidates
)
}
private func focusedSyntheticClickRecipient(
targetPID: pid_t,
targetCandidates: () -> [WindowCandidate]
) -> SyntheticMouseClickDelivery.Recipient? {
) -> SyntheticMouseClickDelivery.RecipientObservation {
let systemWide = AXUIElementCreateSystemWide()
guard let focusedApp = copyElement(systemWide, kAXFocusedApplicationAttribute as String),
let ownerPID = pidAttribute(focusedApp),
let focusedWindow = copyElement(systemWide, kAXFocusedWindowAttribute as String) ??
copyElement(focusedApp, kAXFocusedWindowAttribute as String)
else {
return nil
let focusedApp: AXUIElement
switch copyElementProbe(systemWide, kAXFocusedApplicationAttribute as String) {
case let .value(value):
focusedApp = value
case .absent:
return .dismissed
case .unavailable:
return .unavailable
}
guard let ownerPID = pidAttribute(focusedApp) else { return .unavailable }
let focusedWindow: AXUIElement
switch copyElementProbe(focusedApp, kAXFocusedWindowAttribute as String) {
case let .value(value):
focusedWindow = value
case .absent:
guard ownerPID == targetPID else { return .unavailable }
return .dismissed
case .unavailable:
return .unavailable
}
if let windowId = windowNumber(focusedWindow) {
return syntheticClickRecipient(pid: ownerPID, windowId: windowId)
return .focused(syntheticClickRecipient(pid: ownerPID, windowId: windowId))
}
guard ownerPID == targetPID else { return nil }
guard ownerPID == targetPID else { return .unavailable }
guard let frame = absoluteFrame(focusedWindow),
let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate(
from: targetCandidates(),
@@ -1212,35 +1269,38 @@ private func focusedSyntheticClickRecipient(
windowFramesMatch($0.bounds, frame)
}
)
else {
return nil
}
return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId)
else { return .unavailable }
return .focused(syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId))
}
private func hitTestSyntheticClickRecipient(
at point: CGPoint,
targetPID: pid_t,
targetCandidates: () -> [WindowCandidate]
) -> SyntheticMouseClickDelivery.Recipient? {
) -> SyntheticMouseClickDelivery.RecipientObservation {
let systemWide = AXUIElementCreateSystemWide()
var hitElement: AXUIElement?
guard AXUIElementCopyElementAtPosition(
switch AXUIElementCopyElementAtPosition(
systemWide,
Float(point.x),
Float(point.y),
&hitElement
) == .success,
let hitElement,
) {
case .success:
break
case .noValue:
return .dismissed
default:
return .unavailable
}
guard let hitElement,
let ownerPID = pidAttribute(hitElement),
let window = containingWindow(hitElement)
else {
return nil
}
else { return .unavailable }
if let windowId = windowNumber(window) {
return syntheticClickRecipient(pid: ownerPID, windowId: windowId)
return .focused(syntheticClickRecipient(pid: ownerPID, windowId: windowId))
}
guard ownerPID == targetPID else { return nil }
guard ownerPID == targetPID else { return .unavailable }
guard let frame = absoluteFrame(window),
let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate(
from: targetCandidates(),
@@ -1248,10 +1308,8 @@ private func hitTestSyntheticClickRecipient(
windowFramesMatch($0.bounds, frame)
}
)
else {
return nil
}
return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId)
else { return .unavailable }
return .focused(syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId))
}
private func containingWindow(_ element: AXUIElement) -> AXUIElement? {
@@ -1495,16 +1553,21 @@ private func stringAttribute(_ element: AXUIElement, _ attribute: String) -> Str
}
private func rawStringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
let value,
CFGetTypeID(value) == CFStringGetTypeID()
guard let value = rawAttributeValue(element, attribute), CFGetTypeID(value) == CFStringGetTypeID()
else {
return nil
}
return value as? String
}
private func rawAttributeValue(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else {
return nil
}
return value
}
private func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else {
@@ -2512,7 +2575,7 @@ private enum Input {
try SyntheticMouseClickDelivery.deliver(
clickCount: count,
target: target,
currentRecipient: {
currentObservation: {
currentSyntheticClickRecipient(snapshot: targetWindow, point: point)
},
makeEvent: { step in
@@ -0,0 +1,165 @@
import CoreFoundation
import Foundation
public struct AttributeValueCoercion: Equatable {
public enum Value: Equatable {
case string(String)
case integer(Int64)
case double(Double)
case boolean(Bool)
public var preview: String {
switch self {
case let .string(value):
return value
case let .integer(value):
return String(value)
case let .double(value):
return String(value)
case let .boolean(value):
return String(value)
}
}
}
public enum ReadbackComparison: Equatable {
case match(actualPreview: String)
case mismatch(actualPreview: String)
case unsupported
}
public let writeValue: Value
public init(existingValue: CFTypeRef?, requested: String) {
guard let existingValue, let current = Self.decode(existingValue) else {
writeValue = .string(requested)
return
}
writeValue = Self.coerce(requested, matching: current) ?? .string(requested)
}
public func compare(readback: CFTypeRef?) -> ReadbackComparison {
guard let readback, let actual = Self.decode(readback) else {
return .unsupported
}
return actual == writeValue
? .match(actualPreview: actual.preview)
: .mismatch(actualPreview: actual.preview)
}
private static func coerce(_ requested: String, matching current: Value) -> Value? {
let trimmed = requested.trimmingCharacters(in: .whitespacesAndNewlines)
switch current {
case .string:
return .string(requested)
case .integer:
return parseInteger(trimmed).map(Value.integer)
case .double:
guard let value = Double(trimmed), value.isFinite else { return nil }
return .double(value)
case .boolean:
switch trimmed.lowercased() {
case "true", "1":
return .boolean(true)
case "false", "0":
return .boolean(false)
default:
return nil
}
}
}
private static func parseInteger(_ value: String) -> Int64? {
if let integer = Int64(value) {
return integer
}
var unsigned = value[...]
let isNegative = unsigned.first == "-"
if isNegative || unsigned.first == "+" {
unsigned = unsigned.dropFirst()
}
let exponentIndex = unsigned.firstIndex { $0 == "e" || $0 == "E" }
let significand = exponentIndex.map { unsigned[..<$0] } ?? unsigned
let exponentText = exponentIndex.map { unsigned[unsigned.index(after: $0)...] }
guard exponentText?.contains(where: { $0 == "e" || $0 == "E" }) != true else { return nil }
let exponent: Int?
if let exponentText {
let digits = exponentText.first == "+" || exponentText.first == "-"
? exponentText.dropFirst()
: exponentText
guard !digits.isEmpty, digits.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) else {
return nil
}
exponent = Int(exponentText)
} else {
exponent = 0
}
let parts = significand.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2 else { return nil }
let whole = parts[0]
let fraction = parts.count == 2 ? parts[1] : Substring()
guard !whole.isEmpty || !fraction.isEmpty,
whole.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }),
fraction.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 })
else {
return nil
}
let digits = whole + fraction
let significant = digits.drop(while: { $0 == "0" })
guard !significant.isEmpty else { return 0 }
guard let exponent else { return nil }
let trailingZeroCount = significant.reversed().prefix(while: { $0 == "0" }).count
let normalized: String
if exponent >= fraction.count {
let zeroCount = exponent - fraction.count
guard significant.count <= 19, zeroCount <= 19 - significant.count else { return nil }
normalized = String(significant) + String(repeating: "0", count: zeroCount)
} else {
let removedCount: Int
if exponent >= 0 {
removedCount = fraction.count - exponent
} else {
guard fraction.count <= trailingZeroCount,
exponent.magnitude <= UInt(trailingZeroCount - fraction.count)
else {
return nil
}
removedCount = fraction.count + Int(exponent.magnitude)
}
guard removedCount <= trailingZeroCount else { return nil }
normalized = String(significant.dropLast(removedCount))
}
return Int64(isNegative ? "-\(normalized)" : normalized)
}
private static func decode(_ value: CFTypeRef) -> Value? {
let typeID = CFGetTypeID(value)
if typeID == CFStringGetTypeID() {
return (value as? String).map(Value.string)
}
if typeID == CFBooleanGetTypeID() {
return (value as? Bool).map(Value.boolean)
}
guard typeID == CFNumberGetTypeID() else { return nil }
guard let bridgedNumber = value as? NSNumber else { return nil }
let number = bridgedNumber as CFNumber
if CFNumberIsFloatType(number) {
var decoded = 0.0
guard CFNumberGetValue(number, .doubleType, &decoded), decoded.isFinite else { return nil }
return .double(decoded)
}
var decoded: Int64 = 0
guard CFNumberGetValue(number, .sInt64Type, &decoded) else { return nil }
return .integer(decoded)
}
}
@@ -18,6 +18,17 @@ public enum SyntheticMouseClickDelivery {
}
}
public enum RecipientObservation: Equatable, Sendable {
case focused(Recipient)
case dismissed
case unavailable
var recipient: Recipient? {
guard case let .focused(recipient) = self else { return nil }
return recipient
}
}
public enum FenceFailure: Error, Equatable {
case recipientChanged(expected: Recipient, actual: Recipient?, deliveredPresses: Int)
}
@@ -69,19 +80,20 @@ public enum SyntheticMouseClickDelivery {
public static func deliver<Event>(
clickCount: Int,
target: Recipient,
currentRecipient: () -> Recipient?,
currentObservation: () -> RecipientObservation,
makeEvent: (Step) throws -> Event,
post: (Event) -> Void,
pause: (UInt32) -> Void
) throws {
post(try makeEvent(.move))
pause(interEventPauseMicroseconds)
for pressIndex in 1...min(max(clickCount, 1), maxClickCount) {
let beforeDown = currentRecipient()
guard beforeDown == target else {
let pressCount = min(max(clickCount, 1), maxClickCount)
for pressIndex in 1...pressCount {
let beforeDown = currentObservation()
guard beforeDown == .focused(target) else {
throw FenceFailure.recipientChanged(
expected: target,
actual: beforeDown,
actual: beforeDown.recipient,
deliveredPresses: pressIndex - 1
)
}
@@ -90,11 +102,13 @@ public enum SyntheticMouseClickDelivery {
post(down)
pause(interEventPauseMicroseconds)
post(up)
let afterUp = currentRecipient()
guard afterUp == target else {
let afterUp = currentObservation()
// A final mouse-up may dismiss the target, but an unavailable probe is unsafe.
let finalDismissal = pressIndex == pressCount && afterUp == .dismissed
guard afterUp == .focused(target) || finalDismissal else {
throw FenceFailure.recipientChanged(
expected: target,
actual: afterUp,
actual: afterUp.recipient,
deliveredPresses: pressIndex
)
}
@@ -0,0 +1,146 @@
import CoreFoundation
import Foundation
import XCTest
@testable import OrcaComputerUseMacOSCore
final class AttributeValueCoercionTests: XCTestCase {
func testStringValueRemainsAStringWithoutTrimming() {
let coercion = AttributeValueCoercion(existingValue: "old" as CFString, requested: " new ")
XCTAssertEqual(coercion.writeValue, .string(" new "))
}
func testIntegerValuePreservesIntegerKind() {
let coercion = AttributeValueCoercion(existingValue: NSNumber(value: Int32(2)), requested: "3.0")
XCTAssertEqual(coercion.writeValue, .integer(3))
}
func testIntegerDecimalConversionIsExactAboveDoublePrecision() {
let coercion = AttributeValueCoercion(
existingValue: NSNumber(value: Int64(1)),
requested: "9007199254740993.0"
)
XCTAssertEqual(coercion.writeValue, .integer(9_007_199_254_740_993))
XCTAssertEqual(
coercion.compare(readback: NSNumber(value: Int64(9_007_199_254_740_992))),
.mismatch(actualPreview: "9007199254740992")
)
}
func testIntegerScientificNotationIsParsedExactly() {
let cases: [(String, Int64)] = [
("1e3", 1_000),
("9.007199254740993e15", 9_007_199_254_740_993),
("10.0e-1", 1),
("+0.000E999999999999999999999", 0),
("-.0e-999999999999999999999", 0),
]
for (requested, expected) in cases {
XCTAssertEqual(
AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: requested).writeValue,
.integer(expected)
)
}
}
func testIntegerParsingPreservesInt64Boundaries() {
XCTAssertEqual(
AttributeValueCoercion(
existingValue: NSNumber(value: 1),
requested: "9223372036854775807.0"
).writeValue,
.integer(.max)
)
XCTAssertEqual(
AttributeValueCoercion(
existingValue: NSNumber(value: 1),
requested: "-9.223372036854775808e18"
).writeValue,
.integer(.min)
)
}
func testNonIntegralAndOutOfRangeIntegerFormsUseStringFallback() {
for requested in ["1e-1", "9223372036854775808", "-9223372036854775809", "1e999999999999999999999"] {
XCTAssertEqual(
AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: requested).writeValue,
.string(requested)
)
}
}
func testDoubleValuePreservesDoubleKindForIntegralInput() {
let coercion = AttributeValueCoercion(existingValue: NSNumber(value: 2.5), requested: "3")
XCTAssertEqual(coercion.writeValue, .double(3.0))
}
func testBooleanValueAcceptsWordsAndNumericAliases() {
XCTAssertEqual(
AttributeValueCoercion(existingValue: kCFBooleanFalse, requested: "TRUE").writeValue,
.boolean(true)
)
XCTAssertEqual(
AttributeValueCoercion(existingValue: kCFBooleanTrue, requested: "0").writeValue,
.boolean(false)
)
}
func testUnreadableAndUnhandledValuesUseStringFallback() {
XCTAssertEqual(
AttributeValueCoercion(existingValue: nil, requested: "7").writeValue,
.string("7")
)
XCTAssertEqual(
AttributeValueCoercion(existingValue: [1, 2] as CFArray, requested: "7").writeValue,
.string("7")
)
}
func testInvalidHandledValuesUseStringFallback() {
XCTAssertEqual(
AttributeValueCoercion(existingValue: NSNumber(value: 2), requested: "2.5").writeValue,
.string("2.5")
)
XCTAssertEqual(
AttributeValueCoercion(existingValue: NSNumber(value: 2.5), requested: "many").writeValue,
.string("many")
)
XCTAssertEqual(
AttributeValueCoercion(existingValue: kCFBooleanFalse, requested: "maybe").writeValue,
.string("maybe")
)
}
func testReadbackMatchesEachSupportedType() {
let cases: [(AttributeValueCoercion, CFTypeRef, String)] = [
(AttributeValueCoercion(existingValue: "old" as CFString, requested: "new"), "new" as CFString, "new"),
(AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: "2"), NSNumber(value: 2), "2"),
(AttributeValueCoercion(existingValue: NSNumber(value: 1.5), requested: "2.5"), NSNumber(value: 2.5), "2.5"),
(AttributeValueCoercion(existingValue: kCFBooleanFalse, requested: "true"), kCFBooleanTrue, "true"),
]
for (coercion, readback, preview) in cases {
XCTAssertEqual(coercion.compare(readback: readback), .match(actualPreview: preview))
}
}
func testSupportedMismatchedReadbackIncludesActualPreview() {
let coercion = AttributeValueCoercion(existingValue: NSNumber(value: 1), requested: "2")
XCTAssertEqual(
coercion.compare(readback: NSNumber(value: 3)),
.mismatch(actualPreview: "3")
)
}
func testUnreadableAndUnhandledReadbackAreUnsupported() {
let coercion = AttributeValueCoercion(existingValue: "old" as CFString, requested: "new")
XCTAssertEqual(coercion.compare(readback: nil), .unsupported)
XCTAssertEqual(coercion.compare(readback: ["new"] as CFArray), .unsupported)
}
}
@@ -82,7 +82,7 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
target: target,
currentRecipient: { intruder },
currentObservation: { .focused(intruder) },
makeEvent: { $0 },
post: { posted.append($0) },
pause: { _ in }
@@ -96,19 +96,61 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
XCTAssertEqual(posted, [.move])
}
func testRecipientChangeAfterMouseUpStopsUntilStateIsVerified() throws {
func testFinalMouseUpMayDismissTheTargetWithoutFailingTheClick() throws {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202)
var recipients = [target, intruder, target, target]
var firstAttempt: [SyntheticMouseClickDelivery.Step] = []
var observations: [SyntheticMouseClickDelivery.RecipientObservation] = [.focused(target), .dismissed]
var posted: [SyntheticMouseClickDelivery.Step] = []
try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
target: target,
currentObservation: { observations.removeFirst() },
makeEvent: { $0 },
post: { posted.append($0) },
pause: { _ in }
)
XCTAssertEqual(posted, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)])
}
func testFinalUnavailableObservationRemainsFailClosed() {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
var observations: [SyntheticMouseClickDelivery.RecipientObservation] = [.focused(target), .unavailable]
var posted: [SyntheticMouseClickDelivery.Step] = []
XCTAssertThrowsError(
try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
target: target,
currentRecipient: { recipients.removeFirst() },
currentObservation: { observations.removeFirst() },
makeEvent: { $0 },
post: { firstAttempt.append($0) },
post: { posted.append($0) },
pause: { _ in }
)
) { error in
XCTAssertEqual(
error as? SyntheticMouseClickDelivery.FenceFailure,
.recipientChanged(expected: target, actual: nil, deliveredPresses: 1)
)
}
XCTAssertEqual(posted, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)])
}
func testFinalMouseUpRejectsADifferentFocusedRecipient() {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202)
var recipients: [SyntheticMouseClickDelivery.Recipient?] = [target, intruder]
var posted: [SyntheticMouseClickDelivery.Step] = []
XCTAssertThrowsError(
try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
target: target,
currentObservation: {
recipients.removeFirst().map(SyntheticMouseClickDelivery.RecipientObservation.focused) ?? .dismissed
},
makeEvent: { $0 },
post: { posted.append($0) },
pause: { _ in }
)
) { error in
@@ -117,34 +159,19 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
.recipientChanged(expected: target, actual: intruder, deliveredPresses: 1)
)
}
XCTAssertEqual(firstAttempt, [
.move,
.buttonDown(pressIndex: 1),
.buttonUp(pressIndex: 1),
])
var retry: [SyntheticMouseClickDelivery.Step] = []
try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
target: target,
currentRecipient: { recipients.removeFirst() },
makeEvent: { $0 },
post: { retry.append($0) },
pause: { _ in }
)
XCTAssertEqual(retry, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)])
XCTAssertEqual(posted, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)])
}
func testMouseUpPostsBeforeSecondRecipientCheck() throws {
func testMouseUpPostsBeforeRecipientCheckForTheNextPress() throws {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
var trace: [String] = []
try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
clickCount: 2,
target: target,
currentRecipient: {
currentObservation: {
trace.append("recipient")
return target
return .focused(target)
},
makeEvent: { $0 },
post: {
@@ -160,7 +187,9 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
pause: { _ in }
)
XCTAssertEqual(trace, ["move", "recipient", "down", "up", "recipient"])
XCTAssertEqual(trace, [
"move", "recipient", "down", "up", "recipient", "recipient", "down", "up", "recipient"
])
}
func testRecipientChangeBeforeLaterPressReportsCompletedPresses() {
@@ -173,7 +202,9 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
try SyntheticMouseClickDelivery.deliver(
clickCount: 2,
target: target,
currentRecipient: { recipients.removeFirst() },
currentObservation: {
.focused(recipients.removeFirst())
},
makeEvent: { $0 },
post: { posted.append($0) },
pause: { _ in }
@@ -191,7 +222,66 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
])
}
func testMultiClickRevalidatesBeforeEveryPressAndAfterEveryRelease() throws {
func testRecipientChangeAfterIntermediateMouseUpStopsBeforeNextPress() {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202)
var recipients: [SyntheticMouseClickDelivery.Recipient?] = [target, intruder]
var posted: [SyntheticMouseClickDelivery.Step] = []
XCTAssertThrowsError(
try SyntheticMouseClickDelivery.deliver(
clickCount: 2,
target: target,
currentObservation: {
recipients.removeFirst().map(SyntheticMouseClickDelivery.RecipientObservation.focused) ?? .dismissed
},
makeEvent: { $0 },
post: { posted.append($0) },
pause: { _ in }
)
) { error in
XCTAssertEqual(
error as? SyntheticMouseClickDelivery.FenceFailure,
.recipientChanged(expected: target, actual: intruder, deliveredPresses: 1)
)
}
XCTAssertEqual(posted, [
.move,
.buttonDown(pressIndex: 1),
.buttonUp(pressIndex: 1),
])
}
func testIntermediateMouseUpDismissalStopsBeforeNextPress() {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
var recipients: [SyntheticMouseClickDelivery.Recipient?] = [target, nil]
var posted: [SyntheticMouseClickDelivery.Step] = []
XCTAssertThrowsError(
try SyntheticMouseClickDelivery.deliver(
clickCount: 2,
target: target,
currentObservation: {
recipients.removeFirst().map(SyntheticMouseClickDelivery.RecipientObservation.focused) ?? .dismissed
},
makeEvent: { $0 },
post: { posted.append($0) },
pause: { _ in }
)
) { error in
XCTAssertEqual(
error as? SyntheticMouseClickDelivery.FenceFailure,
.recipientChanged(expected: target, actual: nil, deliveredPresses: 1)
)
}
XCTAssertEqual(posted, [
.move,
.buttonDown(pressIndex: 1),
.buttonUp(pressIndex: 1),
])
}
func testMultiClickRevalidatesBeforeEveryPressAndBetweenReleases() throws {
let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101)
var validationCount = 0
var posted: [SyntheticMouseClickDelivery.Step] = []
@@ -199,9 +289,9 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
try SyntheticMouseClickDelivery.deliver(
clickCount: 2,
target: target,
currentRecipient: {
currentObservation: {
validationCount += 1
return target
return .focused(target)
},
makeEvent: { $0 },
post: { posted.append($0) },
@@ -220,7 +310,7 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase {
XCTAssertThrowsError(try SyntheticMouseClickDelivery.deliver(
clickCount: 1,
target: target,
currentRecipient: { target },
currentObservation: { .focused(target) },
makeEvent: { step in
if case .buttonUp = step { throw PreparationFailure.mouseUp }
return step
+3 -1
View File
@@ -43,7 +43,8 @@
"typecheck:node": "tsc --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck": "tsc --noEmit -p config/tsconfig.node.json && tsc --noEmit -p config/tsconfig.tc.cli.json && tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.json",
"typecheck": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
"typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
@@ -76,6 +77,7 @@
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"build": "pnpm run build:desktop && pnpm run build:native",
"build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:node": "pnpm rebuild node-pty",
+24 -24
View File
@@ -18,7 +18,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -45,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -208,25 +208,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9591,39 +9591,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)': {}
abbrev@4.0.0: {}
+12 -2
View File
@@ -25,7 +25,7 @@ Use this skill for desktop UI through `orca computer`. When the requested target
name a specific shell. Replace it with that chosen executable before running the command;
do not create a shell variable or run `ORCA` literally. Blocks that name no shell are
intentionally shell-neutral for POSIX shells, PowerShell, and cmd.exe.
- Prefer `--json`. Screenshot bytes are omitted from JSON and written to `screenshot.path`.
- Prefer `--json`; see Screenshots below for image output.
- Do not push, submit forms, send messages, buy items, delete data, change account settings, or expose secrets unless the user explicitly asked for that action.
- If an app contains sensitive content, read only what the user requested.
@@ -94,6 +94,11 @@ printf '%s' "$TEXT" | ORCA computer set-value --app <app> --element-index <index
## Action Rules
- Read every action's verification separately from whether its provider call succeeded:
- `verified` means the changed value was read back.
- `unverified (accessibility action unasserted)` means the accessibility call succeeded but no post-state assertion was made.
- `unverified (synthetic input)` means input was fired into the void and is unverifiable.
- Missing verification metadata is unverified, including responses from older runtimes.
- Prefer semantic actions: `set-value` for editable fields, `click` for controls, `perform-secondary-action` only for listed action names.
- After any UI-changing action, use the returned state or rerun `get-app-state` before choosing the next element index.
- Use `type-text` only after focusing a field and confirming the app has a focused text receiver; synthetic keyboard delivery is reported as unverified, so inspect the returned state before assuming text landed.
@@ -105,7 +110,12 @@ printf '%s' "$TEXT" | ORCA computer set-value --app <app> --element-index <index
## Screenshots
`get-app-state` returns tree+screenshot. Use the tree for indexes/actions and the screenshot for visual confirmation; failed capture usually means hidden, minimized, off-screen, or permission-blocked.
`get-app-state` and actions request screenshots by default unless `--no-screenshot` is
passed. A successful `--json` capture is normally saved at `result.screenshot.path`; if that
path is absent, use the inline base64 `result.screenshot.data`. Pretty output does not save
images.
Use the tree for indexes/actions and the screenshot for visual confirmation; failed capture usually means hidden, minimized, off-screen, or permission-blocked.
Coordinates passed to `click`, `scroll`, and `drag` are window-local action coordinates. If the screenshot reports `scale` other than `1`, convert visual screenshot pixels before acting:
+2
View File
@@ -270,6 +270,8 @@ Recovery is conditional, never a fixed destructive sequence:
Low-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.
`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 <handle>` when supervision and worker lifecycle state are required.
## Gates And Legacy Inspection
```bash
File diff suppressed because one or more lines are too long

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