Merge remote-tracking branch 'origin/main' into rework-14686-work

This commit is contained in:
Brennan Benson
2026-08-18 02:29:37 -07:00
519 changed files with 33776 additions and 9767 deletions
+6
View File
@@ -12,3 +12,9 @@
/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
@@ -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
+49 -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,12 +73,13 @@ 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
@@ -126,7 +130,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 +138,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 +181,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 +200,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 +211,22 @@ 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
# 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 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
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
@@ -240,8 +254,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 +297,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.
+119 -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
@@ -331,7 +397,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
@@ -471,8 +541,11 @@ jobs:
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 +574,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 +592,32 @@ 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-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 +646,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- xterm_patch_sync
- shell_contracts
- test
- managed_hook_node18
@@ -565,6 +669,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 +681,7 @@ jobs:
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
+3
View File
@@ -105,8 +105,11 @@ 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/xterm-patch-regeneration.md
# Stably CLI (only docs/ are tracked)
.stably/*
+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)
---
-1
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
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"]
}
}
+31 -12
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-17",
"updatedAt": "2026-08-18",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -943,15 +943,16 @@
"providers": ["lan", "tailscale", "cloud-relay"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["lan", "tailscale", "cloud-relay"],
"coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, continuous-outage 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.",
"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-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. 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.",
"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 mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.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": [
@@ -959,8 +960,10 @@
"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": [
@@ -994,6 +997,22 @@
"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": [
@@ -1015,13 +1034,13 @@
],
"evidenceRuns": [
{
"date": "2026-08-17",
"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 mobile/src/transport/mobile-relay-outage-escalation.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/mobile-relay-runtime-failover.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": 2.31,
"summary": "Six focused mobile transport files passed with 82 assertions, including production-lifecycle outage escalation and logical count publication."
"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",
@@ -1043,11 +1062,11 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "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."
"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": "The production diff only projects the existing supervisor failure count through local setters and subscribers; it adds no call to a Relay dial, timer, socket, probe, or wire API. 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."
"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.",
@@ -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,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)
}
})
})
@@ -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')
})
})
+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')
+40 -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,
@@ -106,6 +110,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 },
@@ -96,6 +96,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 +202,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 +220,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 +252,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"')
}
})
})
+34 -12
View File
@@ -17,6 +17,11 @@ import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.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
@@ -128,9 +133,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 +143,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,11 +159,20 @@ 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
)
@@ -214,10 +229,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
@@ -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 -8
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,15 +135,16 @@ 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)])
wslArgs(distro, ['/bin/sh', '-lc', loginProbe])
)
const probeText = probe.stdout.toString('utf8')
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
@@ -172,7 +169,7 @@ async function main() {
const script = `${delay}${buildWslLoginShellCommand(command)}`
result = await run(
'wsl.exe',
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
wslArgs(distro, ['/bin/sh', '-lc', script])
)
const markerOffset = result.stdout.indexOf(outputMarker)
if (markerOffset === -1) {
+1
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",
+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/*"]
}
}
}
@@ -99,3 +99,21 @@ 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.
## 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.
+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.
+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
@@ -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
}
}
@@ -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 = {
@@ -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') {
@@ -4,6 +4,7 @@ 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) {}
@@ -24,10 +25,22 @@ export class LogicalClientConnectionPath {
: 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, attempt?: number): void {
@@ -55,8 +68,13 @@ export class LogicalClientConnectionPath {
private update(apply: () => void): void {
const previousPath = this.pending()
const previousAttempt = this.reconnectAttempt(0)
const previousRejected = this.pairingRejected
apply()
if (previousPath === this.pending() && previousAttempt === this.reconnectAttempt(0)) {
if (
previousPath === this.pending() &&
previousAttempt === this.reconnectAttempt(0) &&
previousRejected === this.pairingRejected
) {
return
}
for (const listener of this.listeners) {
@@ -118,6 +118,24 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
}
}
})
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
@@ -58,7 +58,7 @@ export class MobileEndpointSupervisor {
})
this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog)
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
this.relayReconnect.reportFailureCountTo(logical.setRecoveryAttempt)
this.relayReconnect.reportRecoveryTo(logical)
this.nudgeRouter = new MobileEndpointNudgeRouter({
logical,
controller: this.relayReconnect,
@@ -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()
})
})
@@ -60,6 +60,76 @@ describe('relay reconnect controller', () => {
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.
@@ -280,7 +350,8 @@ describe('relay reconnect controller', () => {
function createController(
onRetry: () => void,
reportFailureCount: (count: number) => void = () => {}
reportFailureCount: (count: number) => void = () => {},
reportPairingRejected: (rejected: boolean) => void = () => {}
): RelayReconnectController {
const controller = new RelayReconnectController(
{
@@ -291,6 +362,9 @@ function createController(
},
onRetry
)
controller.reportFailureCountTo(reportFailureCount)
controller.reportRecoveryTo({
setRecoveryAttempt: reportFailureCount,
setPairingRejected: reportPairingRejected
} as unknown as StableLogicalRpcClient)
return controller
}
@@ -8,6 +8,7 @@ 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'
@@ -25,6 +26,7 @@ type RecoveryGate = 'external-signal' | 'fresh-credential'
export class RelayReconnectController {
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
@@ -47,8 +49,9 @@ export class RelayReconnectController {
return this.failureCount.current()
}
reportFailureCountTo(reporter: (count: number) => void): void {
this.failureCount.reportTo(reporter)
reportRecoveryTo(logical: StableLogicalRpcClient): void {
this.failureCount.reportTo(logical.setRecoveryAttempt)
this.pairingRejection.reportTo(logical.setPairingRejected)
}
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
@@ -110,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
@@ -121,6 +127,9 @@ 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
@@ -139,6 +148,7 @@ export class RelayReconnectController {
completeCredentialRefresh(): void {
if (this.recoveryGate === 'fresh-credential') {
this.pairingRejection.clear()
this.credentials.clearRejected()
this.reset()
}
@@ -155,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
}
@@ -166,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
@@ -230,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)
@@ -241,10 +252,7 @@ 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()
@@ -260,21 +268,13 @@ export class RelayReconnectController {
// 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') {
@@ -359,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 {
@@ -131,6 +131,24 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
}
}
})
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
@@ -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())
}
}
}
@@ -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
@@ -321,6 +321,19 @@ describe('stable logical RPC client', () => {
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')
@@ -52,6 +52,9 @@ export type StableLogicalRpcClient = RpcClient & {
getPendingPath(): MobileConnectionPath | null
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
@@ -276,6 +279,8 @@ export function createStableLogicalRpcClient(
getPendingPath: () => connectionPath.pending(),
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)
}
]
: []
+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: {}
+7 -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.
@@ -105,7 +105,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:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { AgentSessionPaneBindings } from './agent-session-pane-bindings'
import { makePaneKey } from '../../shared/stable-pane-id'
const PANE_A = makePaneKey('tab-1', 'aaaaaaaa-1111-4111-8111-111111111111')
const PANE_B = makePaneKey('tab-1', 'bbbbbbbb-2222-4222-8222-222222222222')
describe('AgentSessionPaneBindings', () => {
it('resolves only the source it was bound under', () => {
const bindings = new AgentSessionPaneBindings()
bindings.bind('claude', 'sess-1', { paneKey: PANE_A, ptyId: 'pty-1' })
expect(bindings.resolve('claude', 'sess-1')?.paneKey).toBe(PANE_A)
expect(bindings.resolve('codex', 'sess-1')).toBeNull()
expect(bindings.resolve('claude', 'sess-2')).toBeNull()
expect(bindings.resolve('claude', null)).toBeNull()
})
it('follows a session that re-binds to another pane', () => {
const bindings = new AgentSessionPaneBindings()
bindings.bind('claude', 'sess-1', { paneKey: PANE_A, ptyId: 'pty-1' })
bindings.bind('claude', 'sess-1', { paneKey: PANE_B, ptyId: 'pty-2' })
expect(bindings.resolve('claude', 'sess-1')?.paneKey).toBe(PANE_B)
expect(bindings.size()).toBe(1)
})
it('drops every binding a dying PTY established, and only those', () => {
const bindings = new AgentSessionPaneBindings()
bindings.bind('claude', 'sess-1', { paneKey: PANE_A, ptyId: 'pty-1' })
bindings.bind('claude', 'sess-2', { paneKey: PANE_A, ptyId: 'pty-1' })
bindings.bind('claude', 'sess-3', { paneKey: PANE_B, ptyId: 'pty-2' })
bindings.clearForPty('pty-1')
expect(bindings.resolve('claude', 'sess-1')).toBeNull()
expect(bindings.resolve('claude', 'sess-2')).toBeNull()
expect(bindings.resolve('claude', 'sess-3')?.paneKey).toBe(PANE_B)
})
it('evicts least-recently-bound sessions past the cap, keeping a re-bound one alive', () => {
const bindings = new AgentSessionPaneBindings()
bindings.bind('claude', 'sess-0', { paneKey: PANE_A, ptyId: 'pty-0' })
for (let i = 1; i < 512; i += 1) {
bindings.bind('claude', `sess-${i}`, { paneKey: PANE_B, ptyId: `pty-${i}` })
}
// Touch the oldest so it is no longer the eviction candidate.
bindings.bind('claude', 'sess-0', { paneKey: PANE_A, ptyId: 'pty-0' })
bindings.bind('claude', 'sess-512', { paneKey: PANE_B, ptyId: 'pty-512' })
expect(bindings.size()).toBe(512)
expect(bindings.resolve('claude', 'sess-0')?.paneKey).toBe(PANE_A)
expect(bindings.resolve('claude', 'sess-1')).toBeNull()
expect(bindings.resolve('claude', 'sess-512')?.paneKey).toBe(PANE_B)
})
it('ignores incomplete bindings rather than storing an unroutable row', () => {
const bindings = new AgentSessionPaneBindings()
bindings.bind('claude', ' ', { paneKey: PANE_A, ptyId: 'pty-1' })
bindings.bind('claude', 'sess-1', { paneKey: '', ptyId: 'pty-1' })
bindings.bind('claude', 'sess-2', { paneKey: PANE_A, ptyId: '' })
expect(bindings.size()).toBe(0)
})
})
@@ -0,0 +1,84 @@
import type { AgentHookSource } from '../../shared/agent-hook-relay'
export type AgentSessionPaneBinding = {
paneKey: string
/** The PTY whose spawn established this binding; the binding dies with it. */
ptyId: string
/** Why carried: the inherited env names the DAEMON's workspace too, so a
* corrected event would otherwise keep filing itself under the wrong one. */
worktreeId?: string
}
// Why: one entry per pinned session. Generous headroom over any realistic live
// count while still bounding a caller that leaks registrations.
const MAX_BINDINGS = 512
/**
* Maps a provider session id to the pane Orca spawned it into.
*
* Why this layer exists: a pane's identity reaches an agent's hooks only
* through `ORCA_PANE_KEY` in the process environment, and a CLI that hosts
* sessions inside a shared prewarmed daemon runs its hooks in a worker that
* inherited the DAEMON's env — whichever pane happened to start the daemon,
* possibly days ago in another workspace. The posted pane key is then simply
* wrong, and the hook payload carries no other pane coordinate, so no
* listener-side heuristic can recover the right one. The binding is therefore
* established where Orca still knows both halves — at spawn, where it mints the
* session id and owns the pane — and consulted at ingest.
*
* Session-scoped rather than process-scoped on purpose: the daemon's worker is
* not the pane's PTY and can outlive it, so process ancestry proves nothing.
* Transport-agnostic for the same reason — a remote host's daemon inherits a
* stale key exactly like a local one, and both ingest seams resolve here.
*/
export class AgentSessionPaneBindings {
private bindings = new Map<string, AgentSessionPaneBinding>()
private static key(source: AgentHookSource, sessionId: string): string {
// Why: source-scoped so two CLIs cannot collide on a shared id namespace.
return `${source}\u0000${sessionId}`
}
bind(source: AgentHookSource, sessionId: string, binding: AgentSessionPaneBinding): void {
const sessionKey = sessionId.trim()
if (!sessionKey || !binding.paneKey || !binding.ptyId) {
return
}
const key = AgentSessionPaneBindings.key(source, sessionKey)
// Why: delete first so re-binding an existing session moves it to the end
// of the insertion order — eviction stays least-recently-bound.
this.bindings.delete(key)
this.bindings.set(key, binding)
while (this.bindings.size > MAX_BINDINGS) {
const oldest = this.bindings.keys().next().value
if (oldest === undefined) {
break
}
this.bindings.delete(oldest)
}
}
resolve(
source: AgentHookSource,
sessionId: string | null | undefined
): AgentSessionPaneBinding | null {
if (!sessionId) {
return null
}
return this.bindings.get(AgentSessionPaneBindings.key(source, sessionId.trim())) ?? null
}
/** Drops every binding a dying PTY established, so a reused id space cannot
* re-route a later session onto a pane that no longer runs it. */
clearForPty(ptyId: string): void {
for (const [key, binding] of this.bindings) {
if (binding.ptyId === ptyId) {
this.bindings.delete(key)
}
}
}
size(): number {
return this.bindings.size
}
}
@@ -526,6 +526,37 @@ describe('wrapPosixHookCommand', () => {
}
)
it('emits a fallback response before draining when the caller supplies one', () => {
const cmd = wrapPosixHookCommand('/does/not/exist.sh', {}, { fallbackStdout: '{"a":"b"}' })
expect(cmd).toBe(
`if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else printf '%s\\n' '{"a":"b"}'; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
)
})
it.skipIf(process.platform === 'win32')(
'writes the fallback response and still drains a large stdin payload',
() => {
const cmd = wrapPosixHookCommand('/does/not/exist.sh', {}, { fallbackStdout: '{"a":"b"}' })
const result = spawnSync('/bin/sh', ['-c', cmd], {
input: Buffer.alloc(1_000_000, 'x'),
encoding: 'utf8'
})
expect(result.status).toBe(0)
expect(result.stdout).toBe('{"a":"b"}\n')
}
)
it.skipIf(process.platform === 'win32')(
'runs the script instead of the fallback when the script is present',
() => {
const scriptPath = join(tmpDir, 'present-hook.sh')
writeFileSync(scriptPath, "#!/bin/sh\nprintf 'from-script\\n'\n", { mode: 0o755 })
const cmd = wrapPosixHookCommand(scriptPath, {}, { fallbackStdout: '{"a":"b"}' })
const result = spawnSync('/bin/sh', ['-c', cmd], { encoding: 'utf8' })
expect(result.stdout).toBe('from-script\n')
}
)
it.skipIf(process.platform === 'win32')(
'drains stdin when a directory occupies the managed script path',
() => {
+1 -15
View File
@@ -13,7 +13,6 @@ import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { grantDirAcl, isPermissionError } from '../win32-utils'
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
import { resolveHooksJsonWritePath } from './hook-config-write-path'
import { writeRollingFileBackup } from '../rolling-file-backup'
import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher'
@@ -106,20 +105,7 @@ export function getSharedManagedScriptPath(scriptFileName: string): string {
return join(homedir(), '.orca', 'agent-hooks', scriptFileName)
}
function quotePosixShellString(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
// Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
const quoted = quotePosixShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
.join(' ')
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
}
export { wrapPosixHookCommand } from './posix-hook-command'
export function quotePowerShellString(value: string): string {
return `'${value.replaceAll("'", "''")}'`
@@ -0,0 +1,25 @@
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
function quotePosixShellString(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
export function wrapPosixHookCommand(
scriptPath: string,
env: Record<string, string> = {},
// Why: silence is a hard deny on gate events (Antigravity PreToolUse, #2426); those callers need the guard to still answer.
options: { fallbackStdout?: string } = {}
): string {
// Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
const quoted = quotePosixShellString(scriptPath)
const envPrefix = Object.entries(env)
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
.join(' ')
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
const fallback =
options.fallbackStdout === undefined
? POSIX_HOOK_STDIN_DRAIN_COMMAND
: `printf '%s\\n' ${quotePosixShellString(options.fallbackStdout)}; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}`
return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${fallback}; fi`
}
@@ -343,14 +343,20 @@ describe('remote hook service installers', () => {
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
}
expect(antigravityConfig['orca-status'].PreToolUse).toBeUndefined()
for (const eventName of ['PostToolUse']) {
for (const eventName of ['PreToolUse', 'PostToolUse']) {
const definition = antigravityConfig['orca-status'][eventName]?.[0]
const command = definition?.hooks?.[0]?.command
expect(definition?.matcher).toBe('*')
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
}
// Why: #2426 was an SSH report — a remote host missing the script must still answer the gate, not deny every tool.
expect(antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command).toContain(
`printf '%s\\n' '{"decision":"ask"}'`
)
expect(antigravityConfig['orca-status'].PostToolUse[0].hooks?.[0]?.command).not.toContain(
'{"decision"'
)
const ampPlugin = amp.fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')
expect(ampPlugin).toContain('/hook/amp')
@@ -527,7 +533,7 @@ describe('remote hook service installers', () => {
}
})
it('removes stale remote Antigravity PreToolUse hooks while installing SSH hooks', async () => {
it('replaces stale remote Antigravity PreToolUse hooks while installing SSH hooks', async () => {
const { sftp, fs } = createFakeSftp()
fs.files.set(
'/home/dev/.gemini/config/hooks.json',
@@ -568,7 +574,12 @@ describe('remote hook service installers', () => {
const config = JSON.parse(fs.files.get('/home/dev/.gemini/config/hooks.json')!) as {
'orca-status': Record<string, { hooks?: { command: string }[] }[]>
}
expect(config['orca-status'].PreToolUse).toBeUndefined()
const preToolCommands = config['orca-status'].PreToolUse.flatMap((definition) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
expect(preToolCommands).toHaveLength(1)
expect(preToolCommands[0]).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
expect(preToolCommands).not.toContain('/tmp/old/agent-hooks/antigravity-hook.sh')
const postToolCommands = config['orca-status'].PostToolUse.flatMap((definition) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
@@ -0,0 +1,247 @@
import { describe, expect, it, vi } from 'vitest'
import { AgentHookServer, isValidPaneKey, _internals } from './server'
import {
createHookListenerState,
normalizeHookPayload,
readHookBodyProviderSessionId
} from '../../shared/agent-hook-listener'
import { makePaneKey } from '../../shared/stable-pane-id'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
// Why: split panes live in ONE tab — same tabId, different leaf. The daemon's
// inherited key names a pane in a different workspace entirely.
const SPLIT_LEAF_A = 'aaaaaaaa-1111-4111-8111-111111111111'
const SPLIT_LEAF_B = 'bbbbbbbb-2222-4222-8222-222222222222'
const DAEMON_LEAF = 'cccccccc-3333-4333-8333-333333333333'
const SPLIT_PANE_A = makePaneKey('tab-split', SPLIT_LEAF_A)
const SPLIT_PANE_B = makePaneKey('tab-split', SPLIT_LEAF_B)
/** The pane that first spawned the shared daemon; every later worker inherits its env. */
const DAEMON_PANE = makePaneKey('tab-daemon', DAEMON_LEAF)
const SESSION_A = '0192a4b1-1111-4111-8111-aaaaaaaaaaaa'
const SESSION_B = '0192a4b1-2222-4222-8222-bbbbbbbbbbbb'
const UNPINNED_SESSION = '0192a4b1-9999-4999-8999-999999999999'
function buildDaemonHostedBody(sessionId: string, prompt: string): Record<string, unknown> {
return {
// The daemon worker's env, NOT the pane the user is typing in.
paneKey: DAEMON_PANE,
tabId: 'tab-daemon',
worktreeId: 'wt-daemon',
env: 'production',
payload: {
hook_event_name: 'UserPromptSubmit',
prompt,
session_id: sessionId
}
}
}
async function withServer(
run: (server: AgentHookServer, post: (body: unknown) => Promise<number>) => Promise<void>
): Promise<void> {
_internals.resetCachesForTests()
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const post = async (body: unknown): Promise<number> => {
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(body)
})
return response.status
}
await run(server, post)
} finally {
server.stop()
}
}
function panesInSnapshot(server: AgentHookServer): { paneKey: string; prompt?: string }[] {
return server
.getStatusSnapshot()
.map((entry) => ({ paneKey: entry.paneKey, prompt: entry.prompt }))
}
describe('shared-daemon pane attribution (local hook path)', () => {
it('routes a daemon-hosted turn to the pane the session was spawned into, not the inherited pane key', async () => {
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
expect(await post(buildDaemonHostedBody(SESSION_A, 'ship the fix'))).toBe(204)
expect(panesInSnapshot(server)).toEqual([{ paneKey: SPLIT_PANE_A, prompt: 'ship the fix' }])
})
})
it('re-files a corrected event under the workspace it was spawned in, not the daemon’s', async () => {
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, {
paneKey: SPLIT_PANE_A,
ptyId: 'pty-a',
worktreeId: 'wt-real'
})
expect(await post(buildDaemonHostedBody(SESSION_A, 'cross-workspace turn'))).toBe(204)
// 'wt-daemon' is the daemon's inherited workspace and must not survive.
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: SPLIT_PANE_A, worktreeId: 'wt-real' })
])
})
})
it('keeps two split panes in one tab on their own rows when both post the same inherited key', async () => {
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
server.bindAgentSessionPane('claude', SESSION_B, { paneKey: SPLIT_PANE_B, ptyId: 'pty-b' })
expect(await post(buildDaemonHostedBody(SESSION_A, 'left pane work'))).toBe(204)
expect(await post(buildDaemonHostedBody(SESSION_B, 'right pane work'))).toBe(204)
// Without per-session binding both turns collapse onto DAEMON_PANE and the
// second prompt clobbers the first.
expect(panesInSnapshot(server).sort((a, b) => a.paneKey.localeCompare(b.paneKey))).toEqual([
{ paneKey: SPLIT_PANE_A, prompt: 'left pane work' },
{ paneKey: SPLIT_PANE_B, prompt: 'right pane work' }
])
})
})
it('leaves an unpinned session on the pane it posted', async () => {
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
expect(await post(buildDaemonHostedBody(UNPINNED_SESSION, 'typed claude'))).toBe(204)
// A hand-typed `claude` in a plain shell has no pin; today's behavior stands.
expect(panesInSnapshot(server)).toEqual([{ paneKey: DAEMON_PANE, prompt: 'typed claude' }])
})
})
it('stops re-routing once the pinning PTY exits', async () => {
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
server.clearAgentSessionPaneBindingsForPty('pty-a')
expect(await post(buildDaemonHostedBody(SESSION_A, 'after pane closed'))).toBe(204)
expect(panesInSnapshot(server)).toEqual([
{ paneKey: DAEMON_PANE, prompt: 'after pane closed' }
])
})
})
it('does not let one agent binding capture another agent that reuses the session id', async () => {
await withServer(async (server, post) => {
// Same id, different hook source: bindings are source-scoped.
server.bindAgentSessionPane('codex', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
expect(await post(buildDaemonHostedBody(SESSION_A, 'claude turn'))).toBe(204)
expect(panesInSnapshot(server)).toEqual([{ paneKey: DAEMON_PANE, prompt: 'claude turn' }])
})
})
it('refuses to bind a pane key the status pipeline cannot route', async () => {
// Why #14018 reported an unroutable "$$<hash>:L$$" key: any key that is not
// `<tabId>:<leafUuid>` is rejected at the hook boundary, so a binding must
// never be able to introduce one.
const remintedShape = '$$q7v2m9c4:L$$'
expect(isValidPaneKey(remintedShape)).toBe(false)
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, { paneKey: remintedShape, ptyId: 'pty-a' })
expect(await post(buildDaemonHostedBody(SESSION_A, 'unroutable target'))).toBe(204)
expect(panesInSnapshot(server)).toEqual([
{ paneKey: DAEMON_PANE, prompt: 'unroutable target' }
])
})
})
})
describe('shared-daemon pane attribution (relay path)', () => {
it('routes a remote daemon-hosted turn to the spawn-pinned pane', () => {
_internals.resetCachesForTests()
const server = new AgentHookServer()
server.bindAgentSessionPane('claude', SESSION_A, {
paneKey: SPLIT_PANE_A,
ptyId: 'pty-remote',
worktreeId: 'wt-real'
})
const event = normalizeHookPayload(
createHookListenerState(),
'claude',
buildDaemonHostedBody(SESSION_A, 'remote turn'),
'production'
)
if (!event) {
throw new Error('normalizeHookPayload rejected a known-good relay fixture')
}
expect(event.providerSession?.id).toBe(SESSION_A)
server.ingestRemote({ ...event, source: 'claude' }, 'conn-1')
expect(panesInSnapshot(server)).toEqual([{ paneKey: SPLIT_PANE_A, prompt: 'remote turn' }])
// Why asserted here too: the relay seam has its own worktree override, and
// 'wt-daemon' is the workspace the stale env named.
expect(server.getStatusSnapshot()[0]?.worktreeId).toBe('wt-real')
})
})
describe('an already-correct pane key', () => {
it('is left alone rather than overwritten from the binding', async () => {
// Why this matters beyond an early-out: the binding also carries a
// worktreeId. A session that posts its own (correct) pane must not have its
// workspace restamped from a binding recorded at spawn time.
await withServer(async (server, post) => {
server.bindAgentSessionPane('claude', SESSION_A, {
paneKey: DAEMON_PANE,
ptyId: 'pty-a',
worktreeId: 'wt-at-spawn'
})
expect(await post(buildDaemonHostedBody(SESSION_A, 'posted its own pane'))).toBe(204)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: DAEMON_PANE, worktreeId: 'wt-daemon' })
])
})
})
})
describe('readHookBodyProviderSessionId', () => {
it('reads the id from both the object and JSON-string payload forms', () => {
const body = buildDaemonHostedBody(SESSION_A, 'x')
expect(readHookBodyProviderSessionId('claude', body)).toBe(SESSION_A)
expect(
readHookBodyProviderSessionId('claude', { ...body, payload: JSON.stringify(body.payload) })
).toBe(SESSION_A)
})
it('ignores a Codex child hook, whose session id belongs to the child not the pane', () => {
expect(
readHookBodyProviderSessionId('codex', {
paneKey: DAEMON_PANE,
payload: { session_id: SESSION_A, agent_id: 'child-1' }
})
).toBeNull()
})
it('returns null for malformed payloads instead of throwing', () => {
expect(readHookBodyProviderSessionId('claude', null)).toBeNull()
expect(readHookBodyProviderSessionId('claude', { payload: '{not json' })).toBeNull()
expect(readHookBodyProviderSessionId('claude', { payload: 7 })).toBeNull()
})
})
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, _internals } from './server'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { AgentStatusObservation } from '../../shared/agent-status-observation'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
trackMock: vi.fn()
}))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const CONNECTION = 'ssh-provenance'
type Observed = { paneKey: string; observation?: AgentStatusObservation }
function collectObservations(server: AgentHookServer): Observed[] {
const seen: Observed[] = []
server.setListener((payload) => {
seen.push({ paneKey: payload.paneKey, observation: payload.observation })
})
return seen
}
function lastObservation(seen: Observed[]): AgentStatusObservation {
const observation = seen.at(-1)?.observation
if (!observation) {
throw new Error('expected the last emitted status to carry an observation')
}
return observation
}
describe('agent status observation provenance', () => {
const servers: AgentHookServer[] = []
beforeEach(() => {
_internals.resetCachesForTests()
getCohortAtEmitMock.mockReturnValue({})
})
afterEach(() => {
for (const server of servers) {
server.stop()
}
servers.length = 0
vi.restoreAllMocks()
})
function newServer(): AgentHookServer {
const server = new AgentHookServer()
servers.push(server)
return server
}
it('stamps relayed hook events as hook-origin transitions', () => {
const server = newServer()
const seen = collectObservations(server)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'claude',
hookEventName: 'PostToolUse',
payload: { state: 'working', prompt: 'relayed', agentType: 'claude' }
},
CONNECTION
)
expect(lastObservation(seen)).toMatchObject({
origin: 'hook',
kind: 'transition',
authorityId: expect.stringMatching(/^main-agent-hooks:/),
observedAt: expect.any(Number)
})
expect(lastObservation(seen).boundary).toBeUndefined()
})
it('stamps main-parsed OSC 9999 rows as osc-origin snapshots', () => {
const server = newServer()
const seen = collectObservations(server)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
payload: { state: 'working', prompt: 'from bytes', agentType: 'codex' }
})
expect(lastObservation(seen)).toMatchObject({ origin: 'osc', kind: 'snapshot' })
})
it('stamps a relay replay as a snapshot, not a fresh transition', () => {
const server = newServer()
const seen = collectObservations(server)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'claude',
hookEventName: 'UserPromptSubmit',
isReplay: true,
payload: { state: 'working', prompt: 'replayed', agentType: 'claude' }
},
CONNECTION
)
expect(lastObservation(seen).kind).toBe('snapshot')
})
it('marks a resume-identity refresh as identity-only rather than a turn transition', () => {
const server = newServer()
const seen = collectObservations(server)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'pi',
hookEventName: 'before_agent_start',
providerSessionOnly: true,
providerSession: {
key: 'session_id',
id: 'pi-session-1',
transcriptPath: '/tmp/pi-session-1.json'
},
payload: { state: 'done', prompt: '', agentType: 'pi' }
},
CONNECTION
)
expect(lastObservation(seen).kind).toBe('identity-only')
})
// Why: this is the whole point of stamping boundary from the listener's own classifier.
// `before_agent_start`, `BeforeAgent` and `agent.start` are NOT the two raw literals the
// retired-pane gate matches, so a second hand-written list here would report them as
// non-boundaries — exactly the defect PR #14626 fixed one instance of.
it.each([
{ source: 'claude', hookEventName: 'UserPromptSubmit', agentType: 'claude' },
{ source: 'pi', hookEventName: 'before_agent_start', agentType: 'pi' },
{ source: 'gemini', hookEventName: 'BeforeAgent', agentType: 'gemini' },
{ source: 'amp', hookEventName: 'agent.start', agentType: 'amp' },
{ source: 'cursor', hookEventName: 'beforeSubmitPrompt', agentType: 'cursor' }
])(
'stamps boundary for $source $hookEventName even though it is not a gate literal',
({ source, hookEventName, agentType }) => {
const server = newServer()
const seen = collectObservations(server)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source,
hookEventName,
payload: { state: 'working', prompt: 'a new turn', agentType }
},
CONNECTION
)
expect(lastObservation(seen).boundary).toBe(true)
}
)
it('does not stamp boundary for a mid-turn event of a boundary-capable provider', () => {
const server = newServer()
const seen = collectObservations(server)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'gemini',
hookEventName: 'PostToolUse',
payload: { state: 'working', prompt: 'mid turn', agentType: 'gemini' }
},
CONNECTION
)
expect(lastObservation(seen).boundary).toBeUndefined()
})
it('advances revision per accepted observation and bumps incarnation when a retired pane restarts', () => {
const server = newServer()
const seen = collectObservations(server)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'claude',
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'first turn', agentType: 'claude' }
},
CONNECTION
)
const first = lastObservation(seen)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'claude',
hookEventName: 'Stop',
payload: { state: 'done', prompt: 'first turn', agentType: 'claude' }
},
CONNECTION
)
const second = lastObservation(seen)
expect(second.revision).toBeGreaterThan(first.revision)
expect(second.incarnation).toBe(first.incarnation)
server.retirePaneAuthority(PANE)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
source: 'claude',
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'reused pane', agentType: 'claude' }
},
CONNECTION
)
const restarted = lastObservation(seen)
expect(restarted.incarnation).toBeGreaterThan(second.incarnation)
expect(restarted.revision).toBeGreaterThan(second.revision)
})
it('never persists the observation to last-status.json', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-observation-'))
try {
const server = newServer()
await server.start({ env: 'production', userDataPath })
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
source: 'claude',
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'persist me', agentType: 'claude' }
},
CONNECTION
)
server.flushStatusPersistSync()
const path = join(userDataPath, 'agent-hooks', 'last-status.json')
expect(existsSync(path)).toBe(true)
const file = readFileSync(path, 'utf8')
// Why: the sequencer that issued it dies with the process, so a stored copy could only
// rehydrate as an ordering claim from a dead authority.
expect(file).not.toContain('observation')
expect(JSON.parse(file).entries[PANE].observation).toBeUndefined()
} finally {
rmSync(userDataPath, { recursive: true, force: true })
}
})
})
+172 -17
View File
@@ -18,6 +18,7 @@ import {
hasCodexTranscriptSubagents,
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
isNewTurnEvent,
markClaudeLeadTurnInterrupted,
markCodexLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
@@ -32,6 +33,7 @@ import {
resolveCachedClaudeCompactOwnership,
resolveHookSource,
preparePendingGrokResultDiscovery,
readHookBodyProviderSessionId,
seedClaudeLeadTurnFromPersistedStatus,
seedClaudeSubagentRosterFromSnapshots,
seedCodexStateFromSnapshot,
@@ -71,6 +73,12 @@ import {
type ParsedAgentStatusPayload,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import {
AgentStatusObservationSequencer,
createAgentStatusAuthorityId,
type AgentStatusObservation,
type AgentStatusObservationOrigin
} from '../../shared/agent-status-observation'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
@@ -91,6 +99,10 @@ import {
type AgentProviderSessionMetadata
} from '../../shared/agent-session-resume'
import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary'
import {
AgentSessionPaneBindings,
type AgentSessionPaneBinding
} from './agent-session-pane-bindings'
export type { AgentHookSource }
@@ -98,6 +110,8 @@ export type { AgentHookSource }
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
stateStartedAt: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
restoredUnconfirmed?: true
/** User-hidden resume identity retained solely for destructive liveness checks. */
@@ -113,7 +127,13 @@ type NormalizedLocalHook = {
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed'
| 'claudeRunningNonAgentTask'
| 'launchToken'
| 'promptInteractionKey'
| 'restoredUnconfirmed'
// Why: revision counters are in-memory and the authority id is regenerated per process, so
// a stored observation could only rehydrate as a stale ordering claim from a dead authority.
| 'observation'
> & {
launchTokenHash?: string
}
@@ -428,6 +448,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
...(entry.observation ? { observation: entry.observation } : {}),
...entry.payload
}
}
@@ -703,6 +724,9 @@ export class AgentHookServer {
})
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
private runtimeObservedStatusPaneKeys = new Set<string>()
// Why: a daemon-hosted session's hooks post the daemon's inherited pane key.
// Spawn-time session pins are the only evidence of the true pane.
private agentSessionPaneBindings = new AgentSessionPaneBindings()
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
private hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
private persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
@@ -728,6 +752,11 @@ export class AgentHookServer {
private connectionTimestampWatermarkById = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
private lastWrittenJson: string | null = null
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
// OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here.
private readonly observations = new AgentStatusObservationSequencer(
createAgentStatusAuthorityId('main-agent-hooks')
)
/**
* Notified once per process when repeated hook POSTs are cut off mid-body (#11217).
@@ -1267,9 +1296,35 @@ export class AgentHookServer {
}
}
/** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet
* (STA-4293) — it is stamped here because every main-side ingress funnels through
* applyNormalizedStatus, so no origin can silently arrive untagged. */
private stampObservation(
payload: AgentHookEventPayload,
origin: AgentStatusObservationOrigin,
observedAt: number
): AgentStatusObservation {
return this.observations.observe(payload.paneKey, {
origin,
observedAt,
// Why: reuse the listener's own per-provider classifier; a second list of raw event-name
// literals here would strand the providers whose boundary event is named anything else.
boundary:
payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName),
kind: payload.providerSessionOnly
? 'identity-only'
: // Why: a replay restates a turn that already happened, and OSC 9999 repaints the
// current state rather than announcing a change — neither is a fresh transition.
payload.isReplay === true || origin === 'osc'
? 'snapshot'
: 'transition'
})
}
private applyNormalizedStatus(
payload: AgentHookEventPayload,
onAccepted?: () => void
onAccepted?: () => void,
origin: AgentStatusObservationOrigin = 'hook'
): EnrichedAgentHookEventPayload {
if (payload.hookEventName === 'UserPromptSubmit') {
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
@@ -1294,7 +1349,10 @@ export class AgentHookServer {
if (payload.providerSessionOnly) {
// Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status.
onAccepted?.()
const enriched = this.attachStatusTiming(payload, now)
const enriched = {
...this.attachStatusTiming(payload, now),
observation: this.stampObservation(payload, origin, now)
}
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
@@ -1425,7 +1483,10 @@ export class AgentHookServer {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, boundaryAwarePayload)
const enriched = this.attachStatusTiming(cachedPayload, now)
const enriched = {
...this.attachStatusTiming(cachedPayload, now),
observation: this.stampObservation(cachedPayload, origin, now)
}
if (
typeof enriched.payload.turnCompletedAt === 'number' &&
Number.isFinite(enriched.payload.turnCompletedAt)
@@ -1816,6 +1877,7 @@ export class AgentHookServer {
this.runtimeObservedStatusPaneKeys.delete(key)
this.currentAuthorityObservations.delete(key)
this.promptSentDedupeByPaneKey.delete(key)
this.observations.forget(key)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
@@ -1959,6 +2021,66 @@ export class AgentHookServer {
return changed
}
/** Records the pane a freshly spawned agent session belongs to. Called from
* the spawn path, which is the last place both halves are known: the session
* id Orca minted into the launch command, and the pane it launched into. */
bindAgentSessionPane(
source: AgentHookSource,
sessionId: string,
binding: AgentSessionPaneBinding
): void {
if (!isValidPaneKey(binding.paneKey)) {
return
}
this.agentSessionPaneBindings.bind(source, sessionId, binding)
}
clearAgentSessionPaneBindingsForPty(ptyId: string): void {
this.agentSessionPaneBindings.clearForPty(ptyId)
}
/** The pane a session was spawned into, when it differs from the key the
* event posted. Null means "no correction" — an unpinned session (a
* hand-typed `claude`, an agent Orca never launched) keeps today's behavior,
* so this can only ever move status ONTO a pane Orca itself started. */
private resolveBoundPaneOverride(
source: AgentHookSource,
sessionId: string | null,
postedPaneKey: unknown
): AgentSessionPaneBinding | null {
const bound = this.agentSessionPaneBindings.resolve(source, sessionId)
if (!bound || bound.paneKey === postedPaneKey) {
return null
}
return bound
}
private normalizeHookBodyAgentSessionPane(source: AgentHookSource, body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return body
}
const record = body as Record<string, unknown>
const bound = this.resolveBoundPaneOverride(
source,
readHookBodyProviderSessionId(source, body),
typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
)
if (!bound) {
return body
}
// Why: the posted key came from the worker's inherited env; the spawn-time
// binding names the pane that actually owns this session. Rewrite tabId
// with it too, or normalizeHookPayload's tabId/paneKey agreement check
// rejects the event outright — and the worktree, since the same inherited
// env named the daemon's workspace.
return {
...record,
paneKey: bound.paneKey,
tabId: parsePaneKey(bound.paneKey)?.tabId,
...(bound.worktreeId ? { worktreeId: bound.worktreeId } : {})
}
}
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return body
@@ -2095,14 +2217,18 @@ export class AgentHookServer {
? previous.providerSession
: undefined
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
this.applyNormalizedStatus({
paneKey,
tabId,
worktreeId,
connectionId,
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
payload: event.payload
})
this.applyNormalizedStatus(
{
paneKey,
tabId,
worktreeId,
connectionId,
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
payload: event.payload
},
undefined,
'osc'
)
}
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
@@ -2147,7 +2273,18 @@ export class AgentHookServer {
}
// Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
const physicalPaneKey = envelope.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
// Why: a remote host's shared agent daemon inherits a stale ORCA_PANE_KEY
// exactly like a local one, so SSH/WSL panes need the same spawn-time
// session binding, resolved before pane-migration aliasing applies on top.
const boundPane = source
? this.resolveBoundPaneOverride(
source,
normalizeAgentProviderSession(envelope.providerSession)?.id ?? null,
physicalPaneKey
)
: null
const paneKey = this.resolvePaneKeyAlias(boundPane?.paneKey ?? physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
@@ -2182,7 +2319,6 @@ export class AgentHookServer {
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined
const compactTrigger =
@@ -2197,10 +2333,19 @@ export class AgentHookServer {
if (statusDisposition === 'suppress') {
return
}
if (statusDisposition === 'restart') {
// Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session.
// Why paneKey, not envelope.paneKey: it is already corrected to the session's
// true pane, so the rebind cannot land on the daemon's inherited key.
this.observations.rebind(paneKey)
}
// Why: a corrected pane brings its own workspace — the envelope's came from
// the same inherited env that named the wrong pane.
const worktreeId =
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
boundPane?.worktreeId ??
(envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
: undefined
: undefined)
const promptInteractionKey =
typeof envelope.promptInteractionKey === 'string' &&
envelope.promptInteractionKey.trim().length > 0
@@ -2399,7 +2544,10 @@ export class AgentHookServer {
}
trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
// Why: session attribution runs first — it corrects an inherited key to
// the real pane, and pane-migration aliasing then applies on top of it.
const attributedBody = this.normalizeHookBodyAgentSessionPane(source, body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(attributedBody)
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
@@ -2412,6 +2560,11 @@ export class AgentHookServer {
statusDisposition === 'restart'
? { ...normalized.event, launchToken: undefined }
: normalized.event
if (statusDisposition === 'restart') {
// Why: a retired pane accepting a new turn is a different agent session behind the
// same key — later observations must not be ordered against the retired one.
this.observations.rebind(event.paneKey)
}
this.recordCurrentAuthorityObservation(event)
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
@@ -3053,6 +3206,8 @@ export class AgentHookServer {
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
// Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload).
observation: _observation,
launchToken,
...persistedPayload
} = enrichedPayload
+10 -2
View File
@@ -12,8 +12,12 @@ export const ANTIGRAVITY_EVENTS = [
windowsWrapperFileName: 'antigravity-post-invocation.cmd'
},
{ eventName: 'Stop', schema: 'direct', windowsWrapperFileName: 'antigravity-stop.cmd' },
// Why: Antigravity requires PreToolUse hooks to make permission decisions.
// Orca's hook is observational, so installing there can block user tools.
// PreToolUse is the only pre-tool signal Antigravity emits; without it panes show a bare "Working" spinner (#12898).
{
eventName: 'PreToolUse',
schema: 'tool',
windowsWrapperFileName: 'antigravity-pre-tool-use.cmd'
},
{
eventName: 'PostToolUse',
schema: 'tool',
@@ -21,6 +25,10 @@ export const ANTIGRAVITY_EVENTS = [
}
] as const
// Why: Antigravity requires a decision on PreToolUse and reads silence as deny (#2426). Of the documented values
// only "ask" defers to the user's permission config — "allow" would auto-approve every tool call Orca observes.
export const ANTIGRAVITY_PRE_TOOL_USE_DECISION = '{"decision":"ask"}'
export type AntigravityEvent = (typeof ANTIGRAVITY_EVENTS)[number]
export const ANTIGRAVITY_MANAGED_SCRIPT_FILE_NAMES = [
+9 -2
View File
@@ -4,6 +4,7 @@ import {
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
} from '../agent-hooks/hook-stdin-contract'
import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
@@ -12,6 +13,8 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'setlocal',
'if /I "%ORCA_ANTIGRAVITY_EVENT%"=="Stop" (',
' echo {"decision":""}',
') else if /I "%ORCA_ANTIGRAVITY_EVENT%"=="PreToolUse" (',
` echo ${ANTIGRAVITY_PRE_TOOL_USE_DECISION}`,
') else (',
' echo {}',
')',
@@ -30,10 +33,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' Stop)',
' printf \'{"decision":""}\\n\'',
' ;;',
' PreToolUse)',
` printf '${ANTIGRAVITY_PRE_TOOL_USE_DECISION}\\n'`,
' ;;',
' *)',
// Why: Antigravity accepts an empty JSON object for passive status hooks;
// returning allow/ask/deny from PreToolUse would change the user's tool
// permission policy.
// only the PreToolUse gate rejects it, and that branch answers above.
' printf "{}\\n"',
' ;;',
'esac',
@@ -79,6 +84,8 @@ export function getWindowsWrapperScript(eventName: string): string {
')',
'if /I "%ORCA_ANTIGRAVITY_EVENT%"=="Stop" (',
' echo {"decision":""}',
') else if /I "%ORCA_ANTIGRAVITY_EVENT%"=="PreToolUse" (',
` echo ${ANTIGRAVITY_PRE_TOOL_USE_DECISION}`,
') else (',
' echo {}',
')',
+109 -7
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -25,6 +26,11 @@ const ANTIGRAVITY_PRE_INVOCATION_COMMAND =
process.platform === 'win32' ? 'antigravity-pre-invocation.cmd' : 'antigravity-hook.sh'
const ANTIGRAVITY_POST_TOOL_USE_COMMAND =
process.platform === 'win32' ? 'antigravity-post-tool-use.cmd' : 'antigravity-hook.sh'
const ANTIGRAVITY_PRE_TOOL_USE_COMMAND =
process.platform === 'win32' ? 'antigravity-pre-tool-use.cmd' : 'antigravity-hook.sh'
// Why: the gate decision Orca is allowed to emit — "allow" would auto-approve every observed tool call.
const PRE_TOOL_USE_DECISION = '{"decision":"ask"}'
const POLICY_OVERRIDING_DECISIONS = ['allow', 'deny', 'force_ask', 'deny_unless_prior_grant']
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -67,9 +73,12 @@ describe('AntigravityHookService', () => {
>
}
expect(Object.keys(config['orca-status']).sort()).toEqual(
['PostInvocation', 'PostToolUse', 'PreInvocation', 'Stop'].sort()
['PostInvocation', 'PostToolUse', 'PreInvocation', 'PreToolUse', 'Stop'].sort()
)
expect(config['orca-status'].PreToolUse[0].matcher).toBe('*')
expect(config['orca-status'].PreToolUse[0].hooks?.[0]?.command).toContain(
ANTIGRAVITY_PRE_TOOL_USE_COMMAND
)
expect(config['orca-status'].PreToolUse).toBeUndefined()
expect(config['orca-status'].PostToolUse[0].matcher).toBe('*')
expect(config['orca-status'].PreInvocation[0].command).toContain(
ANTIGRAVITY_PRE_INVOCATION_COMMAND
@@ -105,9 +114,85 @@ describe('AntigravityHookService', () => {
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
expect(script).toContain('{"decision":""}')
expect(script).toContain(PRE_TOOL_USE_DECISION)
for (const decision of POLICY_OVERRIDING_DECISIONS) {
expect(script).not.toContain(`{"decision":"${decision}"}`)
}
})
it('installs Windows event wrappers without nested cmd quoting and removes stale PreToolUse hooks', () => {
it.skipIf(process.platform === 'win32')(
'answers the PreToolUse gate with ask so the hook never decides tool permissions',
() => {
new AntigravityHookService().install()
const result = spawnSync(
'/bin/sh',
[join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.sh')],
{
env: {
...process.env,
ORCA_ANTIGRAVITY_EVENT: 'PreToolUse',
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: '',
ORCA_AGENT_HOOK_TOKEN: '',
ORCA_PANE_KEY: ''
},
input: '{"toolCall":{"name":"run_command","args":{"CommandLine":"ls"}}}',
encoding: 'utf8'
}
)
expect(result.status).toBe(0)
// Why: Antigravity reads silence/`{}` on PreToolUse as a deny (#2426), so the exact payload is the contract.
expect(result.stdout).toBe(`${PRE_TOOL_USE_DECISION}\n`)
}
)
it.skipIf(process.platform === 'win32')(
'keeps answering the PreToolUse gate when the managed script is missing',
() => {
new AntigravityHookService().install()
rmSync(join(homeDir, '.orca', 'agent-hooks'), { recursive: true, force: true })
const config = JSON.parse(
readFileSync(join(homeDir, '.gemini', 'config', 'hooks.json'), 'utf8')
) as { 'orca-status': Record<string, { hooks?: { command: string }[] }[]> }
const command = config['orca-status'].PreToolUse[0].hooks?.[0]?.command
const result = spawnSync('/bin/sh', ['-c', command!], {
input: '{"toolCall":{"name":"run_command"}}',
encoding: 'utf8'
})
// Why: hooks.json lives in ~/.gemini and outlives ~/.orca, so a swept script must not deny every tool call.
expect(result.status).toBe(0)
expect(result.stdout).toBe(`${PRE_TOOL_USE_DECISION}\n`)
}
)
it.skipIf(process.platform === 'win32')(
'leaves non-gate Antigravity events silent when the managed script is missing',
() => {
new AntigravityHookService().install()
rmSync(join(homeDir, '.orca', 'agent-hooks'), { recursive: true, force: true })
const config = JSON.parse(
readFileSync(join(homeDir, '.gemini', 'config', 'hooks.json'), 'utf8')
) as {
'orca-status': Record<string, { command?: string; hooks?: { command: string }[] }[]>
}
const postToolUse = config['orca-status'].PostToolUse[0].hooks?.[0]?.command
const preInvocation = config['orca-status'].PreInvocation[0].command
for (const command of [postToolUse, preInvocation]) {
const result = spawnSync('/bin/sh', ['-c', command!], { input: '{}', encoding: 'utf8' })
expect(result.status).toBe(0)
expect(result.stdout).toBe('')
}
}
)
it('installs Windows event wrappers without nested cmd quoting and replaces stale PreToolUse hooks', () => {
withPlatform('win32', () => {
const configPath = join(homeDir, '.gemini', 'config', 'hooks.json')
const staleScriptPath = join(
@@ -155,18 +240,20 @@ describe('AntigravityHookService', () => {
{ matcher?: string; command?: string; hooks?: { command: string }[] }[]
>
}
expect(config['orca-status'].PreToolUse).toBeUndefined()
expect(config['orca-status'].PreToolUse).toHaveLength(1)
const expectedWrappers = {
PreInvocation: 'antigravity-pre-invocation.cmd',
PostInvocation: 'antigravity-post-invocation.cmd',
Stop: 'antigravity-stop.cmd',
PreToolUse: 'antigravity-pre-tool-use.cmd',
PostToolUse: 'antigravity-post-tool-use.cmd'
}
for (const [eventName, wrapperFileName] of Object.entries(expectedWrappers)) {
const definition = config['orca-status'][eventName][0]
const command =
eventName === 'PostToolUse' ? definition.hooks?.[0]?.command : definition.command
const command = ['PreToolUse', 'PostToolUse'].includes(eventName)
? definition.hooks?.[0]?.command
: definition.command
expect(createManagedCommandMatcher(wrapperFileName)(command)).toBe(true)
expect(command).not.toContain('cmd /d /s /c')
expect(command).not.toContain('ORCA_ANTIGRAVITY_EVENT')
@@ -174,6 +261,13 @@ describe('AntigravityHookService', () => {
const wrapper = readFileSync(join(homeDir, '.orca', 'agent-hooks', wrapperFileName), 'utf8')
expect(wrapper).toContain(`set "ORCA_ANTIGRAVITY_EVENT=${eventName}"`)
expect(wrapper).toContain('call "%ORCA_ANTIGRAVITY_CORE%"')
// Why: the wrapper is the stdin owner when the core script is gone, so it must answer the gate itself.
if (eventName === 'PreToolUse') {
expect(wrapper).toContain(`echo ${PRE_TOOL_USE_DECISION}`)
}
for (const decision of POLICY_OVERRIDING_DECISIONS) {
expect(wrapper).not.toContain(`{"decision":"${decision}"}`)
}
}
const script = readFileSync(
@@ -254,7 +348,15 @@ describe('AntigravityHookService', () => {
'orca-status': Record<string, { command?: string; hooks?: { command: string }[] }[]>
}
expect(config['orca-status'].OldEvent).toBeUndefined()
expect(config['orca-status'].PreToolUse).toBeUndefined()
// Why: the pre-a480e6b7 PreToolUse entry pointed at a script with no gate branch; it must be replaced, not kept.
const preToolCommands = config['orca-status'].PreToolUse.flatMap((definition) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
expect(preToolCommands).toHaveLength(1)
expect(preToolCommands[0]).toContain(
join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_PRE_TOOL_USE_COMMAND)
)
expect(preToolCommands[0]).not.toContain('/tmp/old/agent-hooks/antigravity-hook.sh')
const commands = config['orca-status'].PostToolUse.flatMap((definition) =>
(definition.hooks ?? []).map((hook) => hook.command)
)
+16 -4
View File
@@ -17,7 +17,11 @@ import {
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { ANTIGRAVITY_EVENTS, type AntigravityEvent } from './hook-events'
import {
ANTIGRAVITY_EVENTS,
ANTIGRAVITY_PRE_TOOL_USE_DECISION,
type AntigravityEvent
} from './hook-events'
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
import {
buildInstalledConfig,
@@ -46,11 +50,20 @@ function getWindowsWrapperScriptPath(event: AntigravityEvent): string {
return getSharedManagedScriptPath(event.windowsWrapperFileName)
}
function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): string {
return wrapPosixHookCommand(
scriptPath,
{ ORCA_ANTIGRAVITY_EVENT: event.eventName },
// Why: a missing managed script must not brick tools; the guard answers PreToolUse itself instead of staying silent.
event.eventName === 'PreToolUse' ? { fallbackStdout: ANTIGRAVITY_PRE_TOOL_USE_DECISION } : {}
)
}
function getManagedCommand(scriptPath: string, event: AntigravityEvent): string {
if (process.platform === 'win32') {
return wrapWindowsCmdHookCommand(getWindowsWrapperScriptPath(event))
}
return wrapPosixHookCommand(scriptPath, { ORCA_ANTIGRAVITY_EVENT: event.eventName })
return getPosixManagedCommand(scriptPath, event)
}
export class AntigravityHookService {
@@ -174,8 +187,7 @@ export class AntigravityHookService {
buildInstalledConfig(
config,
(event) =>
wrapPosixHookCommand(remoteScriptPath, { ORCA_ANTIGRAVITY_EVENT: event.eventName }),
(event) => getPosixManagedCommand(remoteScriptPath, event),
createAntigravityManagedCommandMatcher()
)
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
@@ -215,7 +215,7 @@ describe('ClaudeAccountService credential capture', () => {
[
'-d',
'Ubuntu Test',
'--',
'--exec',
'bash',
'-lc',
"export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'"
@@ -1090,7 +1090,7 @@ export class ClaudeRuntimeAuthService {
[
'-d',
wslInfo.distro,
'--',
'--exec',
'bash',
'-lc',
buildEncodedWslBashCommand(
+6 -6
View File
@@ -667,7 +667,7 @@ export class ClaudeAccountService {
[
'-d',
location.wslDistro,
'--',
'--exec',
'bash',
'-lc',
'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"'
@@ -698,7 +698,7 @@ export class ClaudeAccountService {
[
'-d',
tempConfig.wslDistro,
'--',
'--exec',
'bash',
'-lc',
`rm -rf -- ${shellQuote(tempConfig.linuxPath)}`
@@ -915,7 +915,7 @@ export class ClaudeAccountService {
const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : []
const infoOutput = execFileSync(
'wsl.exe',
[...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
[...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
{ encoding: 'utf-8', timeout: 5000 }
)
const [rawDistro, rawHome] = infoOutput
@@ -935,7 +935,7 @@ export class ClaudeAccountService {
[
'-d',
distro,
'--',
'--exec',
'bash',
'-lc',
`mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`
@@ -974,7 +974,7 @@ export class ClaudeAccountService {
[
'-d',
wslInfo.distro,
'--',
'--exec',
'bash',
'-lc',
buildEncodedWslBashCommand(
@@ -1058,7 +1058,7 @@ export class ClaudeAccountService {
args: [
'-d',
configDir.wslDistro,
'--',
'--exec',
'bash',
'-lc',
`export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}`
+1 -1
View File
@@ -484,7 +484,7 @@ async function runWslCommand(distro: string, command: string): Promise<string> {
try {
child = execFile(
'wsl.exe',
['-d', distro, '--', 'bash', '-lc', buildEncodedWslBashCommand(command)],
['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(command)],
{
encoding: 'utf8',
timeout: WSL_COMMAND_TIMEOUT_MS
@@ -1212,13 +1212,13 @@ export class CodexRuntimeHomeService {
)
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
// Why: WSL drops bash argv, so keep the script literal; login-shell cleanup turns `exit 0` into status 1, so fall through.
// Why: login-shell cleanup turns `exit 0` into status 1, so fall through.
execFileSync(
'wsl.exe',
[
'-d',
distro,
'--',
'--exec',
'bash',
'-lc',
[
@@ -55,7 +55,7 @@ describe('CodexRuntimeHomeService', () => {
expect(firstCall).toBeDefined()
const [command, args] = firstCall as [string, string[]]
expect(command).toBe('wsl.exe')
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc'])
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc'])
expect(args).toHaveLength(6)
const shellCommand = args[5]
@@ -65,7 +65,7 @@ describe('CodexAccountService config sync', () => {
if (script.includes('command -v codex')) {
throw new Error('bash -ic does not inherit the distro login-shell PATH')
}
if (args.slice(2, 5).join(' ') === '-- sh -c') {
if (args.slice(2, 5).join(' ') === '--exec sh -c') {
expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian'))
return ''
}
@@ -178,7 +178,7 @@ describe('CodexAccountService config sync', () => {
if (script.includes('readlink -f')) {
return `${wslLinuxHomePath}\n`
}
if (args.slice(2, 5).join(' ') === '-- sh -c') {
if (args.slice(2, 5).join(' ') === '--exec sh -c') {
expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian'))
throw new Error('codex missing')
}
+5 -5
View File
@@ -1203,7 +1203,7 @@ export class CodexAccountService {
const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : []
const infoOutput = execFileSync(
'wsl.exe',
[...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
[...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
{ encoding: 'utf-8', timeout: 5000 }
)
const [rawDistro, rawHome] = infoOutput
@@ -1223,7 +1223,7 @@ export class CodexAccountService {
[
'-d',
distro,
'--',
'--exec',
'bash',
'-lc',
`mkdir -p ${shellQuote(wslLinuxHomePath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`
@@ -1460,7 +1460,7 @@ export class CodexAccountService {
[
'-d',
wslInfo.distro,
'--',
'--exec',
'bash',
'-lc',
buildEncodedWslBashCommand(
@@ -1519,7 +1519,7 @@ export class CodexAccountService {
[
'-d',
wslInfo.distro,
'--',
'--exec',
'bash',
'-lc',
buildEncodedWslBashCommand(
@@ -1595,7 +1595,7 @@ export class CodexAccountService {
[
'-d',
distro,
'--',
'--exec',
'bash',
'-lc',
buildEncodedWslBashCommand(
@@ -9,7 +9,7 @@ describe('WSL Codex commands', () => {
it('checks the alias-neutral PATH from the distro login shell', () => {
const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev')
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--', 'sh', '-c'])
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--exec', 'sh', '-c'])
expect(args.at(-1)).toContain('getent passwd')
expect(args.at(-1)).toContain('_orca_lookup_command=')
expect(args.at(-1)).toContain('codex')
@@ -22,13 +22,13 @@ describe('WSL Codex commands', () => {
expect(command).toContain('export CODEX_HOME=')
expect(command).toContain('/home/alice/managed-home')
expect(command).toContain('exec "\\$resolved" login')
expect(command).toContain('exec "$resolved" login')
})
it('reports the login-shell binary path and version for identity checks', () => {
const command = buildWslCodexIdentityArgs('Ubuntu').at(-1)
expect(command).toMatch(/printf .*"\\\$resolved"/)
expect(command).toContain('exec "\\$resolved" --version')
expect(command).toMatch(/printf .*"\$resolved"/)
expect(command).toContain('exec "$resolved" --version')
})
})
+2 -9
View File
@@ -1,7 +1,7 @@
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
import {
buildWslExecArgs,
buildWslLoginShellCommand,
escapeWslShCommandForWindows,
quotePosixShell
} from '../../shared/wsl-login-shell-command'
@@ -59,12 +59,5 @@ function buildCodexPathLookup(): string {
function buildWslCodexShellArgs(distro: string, command: string): string[] {
// Why: Codex must use the distro user's configured login shell, whose PATH
// can differ from a hard-coded non-login bash invocation.
return [
'-d',
distro,
'--',
'sh',
'-c',
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
]
return buildWslExecArgs(distro, ['sh', '-c', buildWslLoginShellCommand(command)])
}
@@ -52,7 +52,7 @@ describe('canonicalizeWslLinuxPath', () => {
expect(args).toEqual([
'-d',
'Ubuntu',
'--',
'--exec',
'sh',
'-c',
`if [ ! -d "$1" ]; then printf '%s\\n' '__ORCA_WSL_PATH_MISSING__'; exit 0; fi; readlink -f -- "$1"`,
@@ -91,7 +91,7 @@ describe('canonicalizeWslLinuxPath', () => {
expect(args).toEqual([
'-d',
'Ubuntu',
'--',
'--exec',
'sh',
'-c',
`resolved=$(wslpath -a -u "$1") || exit; if [ ! -d "$resolved" ]; then printf '%s\\n' '__ORCA_WSL_PATH_MISSING__'; exit 0; fi; readlink -f -- "$resolved"`,
@@ -85,7 +85,7 @@ function scheduleWslLinuxPathCanonicalization(
? [
'-d',
distro,
'--',
'--exec',
'sh',
'-c',
`resolved=$(wslpath -a -u "$1") || exit; if [ ! -d "$resolved" ]; then printf '%s\\n' '${WSL_PATH_MISSING_OUTPUT}'; exit 0; fi; readlink -f -- "$resolved"`,
@@ -95,7 +95,7 @@ function scheduleWslLinuxPathCanonicalization(
: [
'-d',
distro,
'--',
'--exec',
'sh',
'-c',
`if [ ! -d "$1" ]; then printf '%s\\n' '${WSL_PATH_MISSING_OUTPUT}'; exit 0; fi; readlink -f -- "$1"`,
@@ -55,7 +55,7 @@ describe('syncWslCodexSessionsIntoManagedHome', () => {
{ timeout?: number; windowsHide?: boolean }
]
expect(command).toBe('wsl.exe')
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc'])
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc'])
expect(args).toHaveLength(6)
expect(options.timeout).toBe(30_000)
expect(options.windowsHide).toBe(true)
@@ -65,9 +65,9 @@ describe('syncWslCodexSessionsIntoManagedHome', () => {
expect(shellCommand).toContain(
"managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'"
)
expect(shellCommand).toContain(`find "\\$source_sessions_root" -type f -name '*.jsonl' -print0`)
expect(shellCommand).toContain('ln -- "\\$source_file" "\\$target_file"')
expect(shellCommand).toContain('if [ -e "\\$target_file" ] || [ -L "\\$target_file" ]; then')
expect(shellCommand).toContain(`find "$source_sessions_root" -type f -name '*.jsonl' -print0`)
expect(shellCommand).toContain('ln -- "$source_file" "$target_file"')
expect(shellCommand).toContain('if [ -e "$target_file" ] || [ -L "$target_file" ]; then')
expect(shellCommand).not.toContain('ln -s')
expect(shellCommand).not.toContain('cp ')
expect(shellCommand).not.toContain('sqlite')
@@ -155,14 +155,14 @@ describe('buildWslCodexSessionBridgeShellCommand', () => {
expect(shellCommand).not.toContain('.sqlite')
})
it('escapes Linux-side shell variable expansion for wsl.exe argv', () => {
it('keeps Linux-side shell variable expansion intact for the guest shell', () => {
const shellCommand = buildWslCodexSessionBridgeShellCommand({
systemSessionsRoot: '/home/alice/.codex/sessions',
managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions'
})
expect(shellCommand).toContain('\\$source_sessions_root')
expect(shellCommand).toContain('\\$source_file')
expect(shellCommand).toContain('\\$((scanned_files + 1))')
expect(shellCommand).toContain('$source_sessions_root')
expect(shellCommand).toContain('$source_file')
expect(shellCommand).toContain('$((scanned_files + 1))')
})
})
+6 -10
View File
@@ -1,6 +1,6 @@
import { execFile } from 'node:child_process'
import { posix as pathPosix } from 'node:path'
import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command'
import { buildWslExecArgs } from '../../shared/wsl-login-shell-command'
import { parseWslUncPath } from '../../shared/wsl-paths'
export type WslCodexSessionBridgeTarget = {
@@ -54,14 +54,10 @@ export async function syncWslCodexSessionsIntoManagedHome(
return emptySummary
}
const stdout = await execFileUtf8('wsl.exe', [
'-d',
target.distro,
'--',
'bash',
'-lc',
buildWslCodexSessionBridgeShellCommand(paths)
])
const stdout = await execFileUtf8(
'wsl.exe',
buildWslExecArgs(target.distro, ['bash', '-lc', buildWslCodexSessionBridgeShellCommand(paths)])
)
return parseWslSessionBridgeSummary(stdout)
}
@@ -110,7 +106,7 @@ export function buildWslCodexSessionBridgeShellCommand(
`done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`,
`printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"`
].join('\n')
return escapeWslShCommandForWindows(shellCommand)
return shellCommand
}
function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string {
+3 -2
View File
@@ -38,8 +38,9 @@ type DumpCandidate = {
readonly size: number
}
// Why: `app.getPath('crashDumps')` is derived from userData, which shifts when
// app.setName runs at whenReady. Snapshot where Crashpad was actually pointed.
// Why: `app.getPath('crashDumps')` is derived from userData, which shifts when app.setName runs
// (at whenReady for packaged builds; before startCrashpadCapture in dev). Snapshot where Crashpad
// was actually pointed.
let crashpadDumpDirectory: string | null = null
let captureStarted = false
let captureStartedAtMs: number | null = null
+62
View File
@@ -7,6 +7,7 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { DaemonClient } from './client'
import type { DaemonPendingRequests } from './daemon-client-pending-requests'
import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson'
import { DaemonConnectionLostError } from './types'
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
import { getDaemonSocketPath } from './daemon-spawner'
@@ -409,6 +410,67 @@ describe('DaemonClient', () => {
expect(result).toEqual({ sessions: [] })
})
it('survives a cancelCreateOrAttach the daemon refuses', async () => {
// Why: v1-v10 daemons have no cancel case and answer with an error. Escalating
// that to a disconnect would reject every sibling session's in-flight request.
const received: string[] = []
await startMockDaemon({
onControlMessage: (msg) => {
const req = msg as { id: string; type: string }
received.push(req.type)
return req.type === 'cancelCreateOrAttach'
? encodeNdjson({
id: req.id,
ok: false,
error: 'Unknown request type: cancelCreateOrAttach'
})
: null
}
})
const disconnected = vi.fn()
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
client.onDisconnected(disconnected)
const sibling = client.request('listSessions', undefined)
const siblingRejected = vi.fn()
void sibling.catch(siblingRejected)
const abort = new AbortController()
const spawn = client.request('createOrAttach', { sessionId: 'aborted' }, 30_000, abort.signal)
void spawn.catch(() => {})
await waitFor(() => received.includes('createOrAttach') && received.includes('listSessions'))
abort.abort()
await waitFor(() => received.includes('cancelCreateOrAttach'))
await new Promise((r) => setTimeout(r, 50))
expect(client.isConnected()).toBe(true)
expect(disconnected).not.toHaveBeenCalled()
expect(siblingRejected).not.toHaveBeenCalled()
})
it('rejects in-flight requests as DaemonConnectionLostError when the socket dies', async () => {
// Why: the error class is the only signal separating "the wire is gone" from
// "the daemon answered with an error"; requestDaemonRpc keys the decision to
// tear down the shared connection off it.
const serverSockets: Socket[] = []
await startMockDaemon({ onControlMessage: () => null })
server.on('connection', (socket) => serverSockets.push(socket))
client = new DaemonClient({ socketPath, tokenPath })
await client.ensureConnected()
const inFlight = client.request('listSessions', undefined)
const rejected = expect(inFlight).rejects.toBeInstanceOf(DaemonConnectionLostError)
await waitFor(() => serverSockets.length > 0)
for (const socket of serverSockets) {
socket.destroy()
}
await rejected
})
it('rejects on error response', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
+9 -2
View File
@@ -2,7 +2,12 @@ import type { Socket } from 'node:net'
import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { encodeNdjson } from './ndjson'
import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
import {
PROTOCOL_VERSION,
NOTIFY_PREFIX,
DaemonConnectionLostError,
DaemonProtocolError
} from './types'
import type { DaemonEndpointIdentity } from './types'
import {
armDaemonSocketCloseHandlers,
@@ -202,7 +207,9 @@ export class DaemonClient {
signal?: AbortSignal
): Promise<T> {
if (!this.connected || !this.controlSocket) {
throw new DaemonProtocolError('Not connected')
// Why: there is no socket to talk on, so this is a transport failure, not a
// refusal by the daemon — see settleCreateCancellation's caller.
throw new DaemonConnectionLostError('Not connected')
}
const generation = this.connectionGeneration
@@ -1,4 +1,4 @@
import { DaemonProtocolError } from './types'
import { DaemonConnectionLostError, DaemonProtocolError } from './types'
import type { RpcResponse } from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
import { decodeDaemonResponseError } from './daemon-errors'
@@ -44,10 +44,14 @@ export class DaemonPendingRequests {
}
}
// Why: only reached when the socket itself is gone, so the error class must stay
// DaemonConnectionLostError — daemon-client-rpc-request keys "may tear down the
// shared connection" off it, and a plain DaemonProtocolError would look like an
// ordinary error reply.
rejectAll(reason: string): void {
for (const [id, pending] of this.requests) {
clearTimeout(pending.timer)
pending.reject(new DaemonProtocolError(reason))
pending.reject(new DaemonConnectionLostError(reason))
this.requests.delete(id)
}
}
@@ -2,6 +2,22 @@ import type { Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DaemonPendingRequests } from './daemon-client-pending-requests'
import { requestDaemonRpc } from './daemon-client-rpc-request'
import { isDaemonGoneError } from './daemon-pty-adapter'
import {
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
DaemonConnectionLostError,
DaemonProtocolError,
DaemonRequestTimeoutError
} from './types'
/** Mirrors DaemonClient: a sibling session's in-flight request on the same connection. */
function addSiblingRequest(pendingRequests: DaemonPendingRequests, reject: () => void): void {
pendingRequests.add('sibling-1', {
resolve: () => {},
reject,
timer: setTimeout(() => {}, 60_000)
})
}
describe('requestDaemonRpc', () => {
afterEach(() => {
@@ -106,7 +122,7 @@ describe('requestDaemonRpc', () => {
expect(pendingRequests.size).toBe(0)
})
it('disconnects when spawn cancellation cannot be confirmed', async () => {
it('disconnects when the cancel could not be put on the wire', async () => {
const pendingRequests = new DaemonPendingRequests()
const abort = new AbortController()
const onCreateCancellationFailure = vi.fn(() => {
@@ -123,7 +139,7 @@ describe('requestDaemonRpc', () => {
unmatchedCancelGraceMs: 5_000,
onCreateCancellationFailure,
settleCreateCancellation: vi.fn(async () => {
throw new Error('settlement failed')
throw new DaemonConnectionLostError('Not connected')
})
})
const rejected = expect(request).rejects.toThrow('Connection lost')
@@ -134,4 +150,138 @@ describe('requestDaemonRpc', () => {
expect(onCreateCancellationFailure).toHaveBeenCalledOnce()
expect(pendingRequests.size).toBe(0)
})
describe('cancel failures the daemon itself produced', () => {
// A cancel the daemon answered — or one that blew its own timeout because the
// daemon's event loop is blocked — says nothing about the sibling sessions
// sharing this connection, so it must never tear the connection down.
async function abortWithFailingCancel(cancelError: Error): Promise<{
request: Promise<unknown>
pendingRequests: DaemonPendingRequests
onCreateCancellationFailure: ReturnType<typeof vi.fn>
rejectSibling: ReturnType<typeof vi.fn>
}> {
const pendingRequests = new DaemonPendingRequests()
const rejectSibling = vi.fn()
addSiblingRequest(pendingRequests, rejectSibling)
const onCreateCancellationFailure = vi.fn(() => {
pendingRequests.rejectAll('Connection lost')
})
const abort = new AbortController()
const request = requestDaemonRpc({
socket: { write: vi.fn() } as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'blocked-spawn' },
timeoutMs: 30_000,
signal: abort.signal,
unmatchedCancelGraceMs: 5_000,
onCreateCancellationFailure,
settleCreateCancellation: vi.fn(async () => {
throw cancelError
})
})
abort.abort()
await vi.advanceTimersByTimeAsync(0)
return { request, pendingRequests, onCreateCancellationFailure, rejectSibling }
}
it('keeps the connection, and stays abort-shaped, when the cancel RPC times out', async () => {
vi.useFakeTimers()
// The caller asked to stop, so a wedged daemon must not turn this into a respawn.
const { request, pendingRequests, onCreateCancellationFailure, rejectSibling } =
await abortWithFailingCancel(
new DaemonRequestTimeoutError('Request cancelCreateOrAttach timed out after 5000ms')
)
const rejected = expect(request).rejects.toThrow('client_disconnected')
expect(onCreateCancellationFailure).not.toHaveBeenCalled()
// Still pending: the bounded grace window, not an immediate teardown.
expect(pendingRequests.size).toBe(2)
await vi.advanceTimersByTimeAsync(5_000)
await rejected
expect(onCreateCancellationFailure).not.toHaveBeenCalled()
expect(rejectSibling).not.toHaveBeenCalled()
expect(pendingRequests.size).toBe(1)
})
it('keeps the connection when the daemon answers the cancel with an error', async () => {
vi.useFakeTimers()
// v1-v10 daemons have no `cancelCreateOrAttach` case at all.
const { request, pendingRequests, onCreateCancellationFailure, rejectSibling } =
await abortWithFailingCancel(
new DaemonProtocolError('Unknown request type: cancelCreateOrAttach')
)
const rejected = expect(request).rejects.toThrow('client_disconnected')
await vi.advanceTimersByTimeAsync(5_000)
await rejected
expect(onCreateCancellationFailure).not.toHaveBeenCalled()
expect(rejectSibling).not.toHaveBeenCalled()
expect(pendingRequests.size).toBe(1)
})
})
it('reports a wedged daemon when the request and its cancel both time out', async () => {
// Why: neither RPC was answered but the socket never closed, so no disconnect fires.
// The only remaining recovery is rejecting with the message isDaemonGoneError matches,
// which lets withDaemonRetry respawn instead of retrying forever against a dead loop.
vi.useFakeTimers()
const pendingRequests = new DaemonPendingRequests()
const rejectSibling = vi.fn()
addSiblingRequest(pendingRequests, rejectSibling)
const onCreateCancellationFailure = vi.fn(() => {
pendingRequests.rejectAll('Connection lost')
})
const request = requestDaemonRpc({
socket: { write: vi.fn() } as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'wedged-spawn' },
timeoutMs: 30_000,
unmatchedCancelGraceMs: 5_000,
onCreateCancellationFailure,
settleCreateCancellation: vi.fn(async () => {
throw new DaemonRequestTimeoutError('Request cancelCreateOrAttach timed out after 5000ms')
})
})
const rejected = expect(request).rejects.toThrow(DAEMON_UNAVAILABLE_RECONNECT_MESSAGE)
await vi.advanceTimersByTimeAsync(30_000)
await vi.advanceTimersByTimeAsync(5_000)
await rejected
// The whole point: withDaemonRetry only respawns for errors this predicate matches.
await request.catch((err: unknown) => expect(isDaemonGoneError(err)).toBe(true))
expect(onCreateCancellationFailure).not.toHaveBeenCalled()
expect(rejectSibling).not.toHaveBeenCalled()
expect(pendingRequests.size).toBe(1)
})
it('drops the pending entry when the control socket write throws', async () => {
const pendingRequests = new DaemonPendingRequests()
const request = requestDaemonRpc({
socket: {
write: vi.fn(() => {
throw new Error('This socket has been ended by the other party')
})
} as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'unsendable-spawn' },
timeoutMs: 30_000,
unmatchedCancelGraceMs: 5_000,
onCreateCancellationFailure: vi.fn(),
settleCreateCancellation: vi.fn(async () => ({ canceled: true }))
})
await expect(request).rejects.toThrow('This socket has been ended by the other party')
expect(pendingRequests.size).toBe(0)
})
})
+69 -17
View File
@@ -1,6 +1,10 @@
import type { Socket } from 'node:net'
import { encodeNdjson } from './ndjson'
import { DaemonProtocolError } from './types'
import {
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
DaemonConnectionLostError,
DaemonRequestTimeoutError
} from './types'
import { isTerminalAttachCanceledMessage } from './daemon-errors'
import type { DaemonPendingRequests } from './daemon-client-pending-requests'
@@ -18,10 +22,37 @@ type DaemonRpcRequestOptions = {
* an attach-only request has nothing coming, so the wait must be bounded.
*/
unmatchedCancelGraceMs: number
/**
* Escalation of last resort: called only when the cancel RPC could not be put on
* the wire at all. Never called for a cancel the daemon answered with an error,
* or one that timed out — those say nothing about the sibling sessions sharing
* this connection. A cancel that timed out is instead reported through
* `wedgedDaemonError` on this request alone.
*/
onCreateCancellationFailure: () => void
settleCreateCancellation: (sessionId: string, requestId: string) => Promise<{ canceled: boolean }>
}
/**
* Why: the request timed out and so did the cancel sent to clean it up, so the daemon
* is wedged with its socket still open — no close event, no disconnect, and nothing
* else in the client notices (#8689 is the same shape). Re-message it to the one
* string `isDaemonGoneError` matches, so the caller's retry can respawn the daemon
* instead of retrying against a daemon that will never answer. Aborts are excluded:
* the caller asked to stop, not to retry.
*/
function wedgedDaemonError(requestError: Error, cancelError: unknown): Error | null {
if (
!(requestError instanceof DaemonRequestTimeoutError) ||
!(cancelError instanceof DaemonRequestTimeoutError)
) {
return null
}
const wedged = new DaemonRequestTimeoutError(DAEMON_UNAVAILABLE_RECONNECT_MESSAGE)
wedged.cause = requestError
return wedged
}
export function requestDaemonRpc<T>(opts: DaemonRpcRequestOptions): Promise<T> {
const { payload, type } = opts
const createSessionId =
@@ -66,6 +97,16 @@ export function requestDaemonRpc<T>(opts: DaemonRpcRequestOptions): Promise<T> {
clearTimers()
reject(error)
}
// Unmatched or unconfirmed cancel: a create that already published a result will
// still answer, so keep waiting — but only for a bounded window, or an
// attach-only request queued behind a hung create never settles at all.
const awaitLateResponseThenReject = (error: Error): void => {
if (settled || unmatchedCancelTimer) {
return
}
unmatchedCancelTimer = setTimeout(() => rejectAndDrop(error), opts.unmatchedCancelGraceMs)
unmatchedCancelTimer.unref?.()
}
const cancelCreate = (error: Error): void => {
if (cancellationStarted) {
return
@@ -84,25 +125,30 @@ export function requestDaemonRpc<T>(opts: DaemonRpcRequestOptions): Promise<T> {
rejectAndDrop(error)
return
}
// Unmatched cancel: a create that already published a result will
// still answer, so keep waiting — but only for a bounded window, or an
// attach-only request queued behind a hung create never settles at all.
if (!settled && !unmatchedCancelTimer) {
unmatchedCancelTimer = setTimeout(
() => rejectAndDrop(error),
opts.unmatchedCancelGraceMs
)
unmatchedCancelTimer.unref?.()
}
awaitLateResponseThenReject(error)
})
.catch(() => {
if (!settled) {
opts.onCreateCancellationFailure()
.catch((cancelError: unknown) => {
if (settled) {
return
}
// Why: a cancel the daemon refused (v1-v10 answer 'Unknown request type')
// or that blew its own 5s timeout (busy event loop, e.g. an unreachable UNC
// share) proves nothing about the other sessions on this connection, so it
// must not tear it down. Only an undeliverable cancel does. Unrecognized
// errors deliberately fall through to the bounded wait: the fail-safe
// direction is leaving siblings alone. A wedged daemon is still reported —
// on this request only — via wedgedDaemonError.
if (cancelError instanceof DaemonConnectionLostError) {
opts.onCreateCancellationFailure()
return
}
awaitLateResponseThenReject(wedgedDaemonError(error, cancelError) ?? error)
})
}
const timer = setTimeout(() => {
const error = new DaemonProtocolError(`Request ${type} timed out after ${opts.timeoutMs}ms`)
const error = new DaemonRequestTimeoutError(
`Request ${type} timed out after ${opts.timeoutMs}ms`
)
if (typeof createSessionId === 'string') {
cancelCreate(error)
} else {
@@ -139,7 +185,13 @@ export function requestDaemonRpc<T>(opts: DaemonRpcRequestOptions): Promise<T> {
onAbort()
return
}
sent = true
opts.socket.write(encoded)
try {
opts.socket.write(encoded)
sent = true
} catch (err) {
// Why: an unwrapped throw here leaks the pending entry and its timer, and the
// raw error would be indistinguishable from a daemon refusal.
rejectAndDrop(new DaemonConnectionLostError(err instanceof Error ? err.message : String(err)))
}
})
}
+33
View File
@@ -21,6 +21,39 @@ export class DaemonProtocolError extends Error {
}
}
/**
* The control connection itself failed: nothing was delivered and no reply is
* coming. Distinct from a DaemonProtocolError the daemon actually answered with
* (or one we raised on our own timeout) — only this class proves the socket, and
* therefore every session sharing it, is already lost. Extends
* DaemonProtocolError and keeps the same messages so existing instanceof and
* message checks (isDaemonGoneError) still match.
*/
export class DaemonConnectionLostError extends DaemonProtocolError {
constructor(message: string) {
super(message)
this.name = 'DaemonConnectionLostError'
}
}
/**
* Nothing came back before our own deadline. The daemon may still be alive and merely
* slow, so this is not a connection loss — but a request AND the cancel sent to clean
* it up both hitting this means the daemon is wedged with its socket still open.
*/
export class DaemonRequestTimeoutError extends DaemonProtocolError {
constructor(message: string) {
super(message)
this.name = 'DaemonRequestTimeoutError'
}
}
/**
* The one message `isDaemonGoneError` matches that a still-open connection can produce,
* so it is the only way a wedged-but-connected daemon reaches the respawn path.
*/
export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavailable; reconnect'
export class SessionNotFoundError extends Error {
constructor(sessionId: string) {
super(`Session not found: ${sessionId}`)
+3 -1
View File
@@ -24,6 +24,7 @@ import {
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact,
@@ -3101,7 +3102,8 @@ export function isDaemonGoneError(err: unknown): boolean {
msg === 'Connection lost' ||
msg === 'Not connected' ||
msg === 'Hello response timed out' ||
msg === 'Daemon temporarily unavailable; reconnect' ||
// Both the daemon's own drain refusal and the client's wedged-daemon signal.
msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE ||
// Why retry: the daemon refused because the endpoint now resolves elsewhere. Reconnecting
// reaches whoever owns it; surfacing this to the user would strand the request instead.
msg === DAEMON_ENDPOINT_LOST_MESSAGE
@@ -1,52 +0,0 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import {
getZshFinalZdotdirRestoreBlock,
getZshStartupFileSourceBlock,
ZSH_HISTFILE_RESTORE_BLOCK
} from '../shell-templates'
export function getDaemonZshShellReadyRcfileContent(): string {
return `# Orca daemon zsh shell-ready wrapper
${getZshStartupFileSourceBlock({
fileName: '.zshrc',
interactiveOnly: true,
skipWhenHomeIsCurrentZdotdir: true
})}
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_agent_teams_path
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${ZSH_HISTFILE_RESTORE_BLOCK}
${getPosixCodexShellLaunchPreflight()}
fi
__orca_osc133_precmd() {
local exit_code=$?
if [[ -n "\${__orca_in_command:-}" ]]; then
printf "\\033]133;D;%s\\007" "$exit_code"
unset __orca_in_command
fi
printf "\\033]133;A\\007"
}
__orca_osc133_preexec() {
printf "\\033]133;C\\007"
__orca_in_command=1
}
# Why: prepend so Orca captures $? before user prompt hooks can overwrite it.
precmd_functions=(__orca_osc133_precmd \${precmd_functions[@]})
preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]})
if [[ ! -o login ]]; then
${getZshFinalZdotdirRestoreBlock()}
fi
`
}
@@ -0,0 +1,25 @@
import type { ZshStartupWrapperSpec } from '../zsh-startup-wrapper-builder'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
/** The zsh wrapper the daemon (local fork and SSH host) launches shells with. */
export function getDaemonZshWrapperSpec(zshDir: string): ZshStartupWrapperSpec {
return {
headerLabel: 'Orca daemon zsh shell-ready wrapper',
zshDir,
zshenvStrategy: 'discover-user-zdotdir',
readyMarkerEscaped: SHELL_READY_MARKER,
osc133CommandMarkers: true,
skipUserZshrcWhenHomeIsWrapperDir: true,
interactiveRestoreComment:
"# Why: ~/.zshrc can export the user's default OpenCode config after spawn.",
loginRestoreComment:
'# Why: .zlogin is the final login startup file before the prompt is shown.',
restores: {
agentTeamsPath: true,
remoteCliBinDir: false,
codexHome: true,
codexLaunchPreflight: true
},
readyMarkerOrder: 'before-zdotdir-restore'
}
}
@@ -187,6 +187,36 @@ describe('createPtySubprocess', () => {
expect(env.ORCA_WORKTREE_ID).toBe('child-worktree')
})
it.each([
// fish EXPORTS fish_history, so a daemon started from a fish pane would hand
// every session the launching worktree's history file (STA-4682). Only the
// name this spawn asked for — the isolated one, or the user's — may stand.
['drops an inherited Orca fish_history', undefined, undefined],
['keeps the session this spawn injected', 'orca_c0ffee', 'orca_c0ffee'],
['keeps a caller-supplied value', 'mine', 'mine']
])('%s', async (_name, requested, expected) => {
spawnMock.mockReturnValue(mockPtyProcess())
const saved = process.env.fish_history
process.env.fish_history = 'orca_abc123'
try {
await createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
...(requested === undefined ? {} : { env: { fish_history: requested } })
})
} finally {
if (saved === undefined) {
delete process.env.fish_history
} else {
process.env.fish_history = saved
}
}
expect(spawnMock.mock.calls.at(-1)?.[2].env.fish_history).toBe(expected)
})
it('does not inherit ELECTRON_RUN_AS_NODE from the daemon process env', async () => {
// Why: the daemon is forked with ELECTRON_RUN_AS_NODE=1. If that flag
// reaches user shells, nested Electron commands run as plain Node.

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