merge: keep main's observation stamping alongside the compact-correlation removal

The conflict is main's per-write `observation` facet landing on the same line as
this branch's removal of `resolveCachedClaudeCompactOwnership`. Taking either side
whole would lose real work: the branch's side drops main's observation stamping,
main's side reinstates a call this branch deliberately deleted as unreachable.

Kept both intents — observation is stamped from `boundaryAwarePayload`, which is
what `cachedPayload` reduced to once the correlation cache was removed.
This commit is contained in:
Brennan Benson
2026-08-18 02:56:26 -07:00
415 changed files with 23172 additions and 8902 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
+39 -26
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
@@ -206,17 +213,20 @@ jobs:
- name: Install native build and headless UI tools
# 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 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
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
@@ -244,7 +254,7 @@ 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)
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.
@@ -287,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.
+107 -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
@@ -528,6 +605,20 @@ jobs:
'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
fi
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
# config/patches is listed because the terminal's input handling lives in the xterm patch.
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
@@ -556,6 +647,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- xterm_patch_sync
- shell_contracts
- test
- managed_hook_node18
@@ -578,6 +670,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 }}
@@ -589,6 +682,7 @@ jobs:
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
+2
View File
@@ -109,6 +109,8 @@ docs/**
!docs/reference/ssh-reconnect-source-recovery.md
!docs/reference/windows-setup-shell.md
!docs/reference/worktree-scan-fingerprint.md
!docs/reference/wsl-command-execution.md
!docs/reference/xterm-patch-regeneration.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1
View File
@@ -34,6 +34,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
+1 -1
View File
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk)
- **Android:** [Download APK 0.0.43](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.43/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
-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')
})
})
+1 -3
View File
@@ -152,9 +152,7 @@ describe('PR E2E gate contract', () => {
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'
)
expect(readFileSync(join(projectDir, spec), 'utf8'), spec).toContain('ORCA_E2E_SSH_DOCKER')
}
}
+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",
+72
View File
@@ -0,0 +1,72 @@
# Running commands inside WSL
Two properties of `wsl.exe` decide how every guest invocation has to be written. Both are silent
when you get them wrong: the command still runs and still exits 0, it just returns the wrong bytes.
## 1. Always `--exec`, never `--`
`wsl.exe -d <distro> -- <argv>` expands `$name` in **every argument** against the guest environment
before the guest runs. This is `wsl.exe` itself, not the guest shell — it happens with no shell in
the command at all:
```
$ wsl.exe -d Ubuntu-24.04 -- /usr/bin/printf %s '$HOME'
/home/you
$ wsl.exe -d Ubuntu-24.04 --exec /usr/bin/printf %s '$HOME'
$HOME
```
So under `--`, a script means something other than what it says. `awk '{print $2}'` reaches the
guest as `awk '{print }'` and prints the whole line; a positional `"$1"`, a shell local, and a
`"\$literal"` are blanked or rewritten the same way. (Expansions with no `$` are unaffected — a
`sed` backreference like `s/(a)(b)/\2\1/` survives either way.) Escaping `$` on the Windows side
cannot fix this reliably — an earlier attempt skipped every `$` preceded by a backslash, which is
exactly the case a POSIX script uses to mean a literal dollar.
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
tree and fails if the `--` form reappears.
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
## 2. Machine-read output must be fenced
Orca runs guest commands through the distro user's **interactive** login shell (`-ilc` for
bash/zsh) because that is the only shell that reads `~/.bashrc`, where `nvm`, `mise` and `asdf`
install their PATH entries. Dropping `-i` would break tool detection for those users.
The cost is that an interactive shell also runs the distro's rc/motd, and that output goes to
**stdout** — the same stream the answer arrives on. Stock Ubuntu 24.04 needs no customization to
reproduce it:
```
$ wsl.exe -d Ubuntu-24.04 --exec bash -ilc 'git --version'
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
git version 2.43.0
```
Any caller that parses stdout must use `buildWslCapturedLoginShellCommand()`, which fences the
payload and returns a matching `readStdout`. `.trim()` does not help: the banner is a prefix, not
surrounding whitespace, so a stat probe compared against `"directory"` simply never matches.
The fence carries a per-call nonce so that `cat`-ing a file whose contents happen to quote a marker
is not truncated, and it preserves the payload's exit status so `exit 2` → `ENOENT` mappings keep
working.
**Do not fence a command that `exec`s into a long-running program** (`codex app-server`, an
interactive terminal). It never reaches the closing fence, and there the shell's own output either
belongs to the program or is what the user wants to see.
## Prefer no shell at all
When a caller only needs a known binary with a known environment, skip the login shell entirely and
run the binary directly:
```
wsl.exe -d <distro> --exec /usr/bin/env PATH=… HOME=… /usr/bin/git -C <dir> status
```
This is what the direct-git read path does. It is immune to both problems above by construction and
avoids paying login-shell startup on every call, which also sidesteps profiles that block or print.
Resolve the PATH/HOME once through a fenced probe, cache it per distro, then use this form.
+244
View File
@@ -0,0 +1,244 @@
# xterm Patch Regeneration
## Scope
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
not taken: the IME composition hooks, the `xterm-composition-*` custom events
they raise, the `ICompositionHelper` surface those hooks widen, and a `SortedList`
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
That patch touches eight files. Four are hand-authored source
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
generated, and this document exists so nobody edits it by hand.
The two halves are the same edits diffed two ways, so the generator requires
them to match byte for byte on every source file. A hunk the shipped patch
cannot name — upstream's `.npmignore` strips `src/**/*.test.ts` — would be
dropped by the next `--write`, so it fails the run instead.
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
3. Every source change must land together with the regenerated bundle hunks and
the `pnpm-lock.yaml` hash bump, in one commit.
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
comment. A version bump that leaves it stale fails the generator, it does not
silently patch the wrong tree.
5. Sourcemaps move with the bundle, and are never silently omitted. The patch
moves the code, so dropping only the map hunks would ship offsets pointing at
the wrong lines. `sourcemaps.policy` accepts `include` and nothing else: it
costs about 5.8 MB of the emitted patch and is required because
`src/renderer/src/components/terminal-pane/terminal-ime-xterm-transaction-events.test.ts`
reads `lib/*.map` and asserts the mapped `Version.ts` matches the runtime
version. Deleting the maps was once an option; the code that did it was
removed as unreachable, so re-adding the policy means re-adding that code.
6. `--check` is the authority on the lockfile, not `pnpm install`. pnpm writes the
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
# 3. Reinstall so node_modules picks up the new patch hash.
pnpm install
# 4. Confirm the tree is self-consistent.
node config/scripts/regenerate-xterm-patches.mjs --check
```
Editing a patch file by hand is awkward for anything larger than a one-liner.
For a substantial change, work in the generator's own checkout instead — after
any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
Run the checkout outside this repository. A build tree underneath it makes
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
identifier`, which is a symptom of where the tree sits and not of the patch.
## How the Commit Is Known
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
each published tarball names the commit that built it. The generator asserts
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
against the checkout file by file. Only `src/common/Version.ts` may differ,
because `publish.js` rewrites the version immediately before packaging; the
generator applies the same stamp.
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
commit would still produce a plausible-looking 7 MB patch.
## Build Order
Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`.
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
50% larger than the published one, which is easy to miss inside a 7 MB diff.
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
The generator also builds the *unmodified* commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
## Recovering From Hand-Edited Bundles
Between 2026-08-09 and 2026-08-17 this harness did not exist, and four fixes
landed by editing the minified bundles directly. The tell is code no minifier
emits: `const` in an otherwise `let`-only bundle, and identifiers like `$rl`,
`$hp`, `$tid`.
Recovery is not a rewrite. The hand-edits were applied to `src/` as well, so the
source hunks in the shipped patch were already correct and `--write` re-derives
the bundles from them. What changes is cosmetic and expected:
- Hand-written locals collapse back into minifier names, which shifts esbuild's
frequency-ordered allocation and can swap two short names bundle-wide (`i`↔`t`
in the `.mjs`, `w`↔`y` in the `.js`). Most differing lines are the same length.
- Hand-written equivalents normalize to what the toolchain actually emits
(`!!x` back to `Boolean(x)`, an escaped `\u200E` back to the literal
character).
To confirm a regeneration is semantically a no-op rather than a revert, compare
identifier multisets between the old and new bundle instead of reading the diff:
every name that is not a single-letter minifier local should appear the same
number of times in both. Anything else is a real change and needs explaining.
## The Lockfile Moves With the Patch
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
the sha256 of the patch file itself. A regenerated patch without the lockfile
bump fails `pnpm install --frozen-lockfile` on every machine except the
author's. `--write` makes that edit; `--check` fails if it is missing.
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
without a network or a build, so the ordinary test job catches lockfile drift
in milliseconds even though the full rebuild runs in its own CI lane.
## Toolchain Pin
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
at the pinned commit, and the generator fails if `npm ci` produces something
else. The entry that matters is `@typescript/native-preview`
(`tsgo`), which upstream pins to a **dated development build** —
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
and npm does not prune old releases, but it is the one dependency of this scheme
that is not a stable release.
If that version ever becomes unresolvable the generator fails with a toolchain
error naming it. Recovery is to move the pin to the next upstream commit whose
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Version Bumps
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
2. Rename both patch files to the new version and update `patch`,
`sourcePatch`, and `version` in `xterm-upstream.json`.
3. Update `upstream.commit` to the `commit` field of the new tarball's
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
rerun. The bundle hunks need no attention at any point.
## Why Not Vendor a Fork
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
the published package, so every upstream beta becomes a merge rather than a
version bump, and Orca inherits responsibility for building and publishing a
package it does not own. The patch is four small source hunks against a commit
that reproduces byte for byte; a fork is a much larger standing cost for the
same result.
## Why Not Handle Composition at Runtime
`CompositionHelper` hooks four private call sites upstream of `onData`, and
`SortedList` has no public surface at all. There is no supported extension point
that reaches either, so a runtime shim would mean reaching into `_core`
internals that upstream renames freely between betas. The patch is the smaller
risk.
## CI Contract
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
twice, and byte-compares the result against the committed patch. Both builds and
the diff together are about eight seconds; `npm ci` for upstream's toolchain is
what the job actually spends its minutes on, and the cache key is the manifest.
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
commit and build-order assertions, and lockfile coupling — with no network and
no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` rules out for `@xterm/xterm`
and which folding them into this manifest would also fix.
Both addons do build from the pinned commit: the registry stamps
`53a98a720ae4a973e384fa2440880d09537132f3` on `addon-webgl@0.20.0-beta.286` and
`addon-serialize@0.15.0-beta.287` alike, despite the mismatched version numbers.
On 2026-08-17 their published `lib/*.mjs` and `lib/*.mjs.map` reproduced byte for
byte from that commit. That was a one-off measurement, not an invariant: no check
in this repo re-runs it, so treat it as a starting point to re-measure rather than
as something the harness holds true.
What blocks folding them in is the other half of their published output. Both
also ship a CJS `lib/addon-*.js`, and the root `package` script does not build
it — upstream's webpack entry is core's `Terminal.js` only, and `postpackage`
emits ESM. So a manifest entry for either addon needs a build step this harness
does not have, and the CJS halves are **unverified**: nothing here has yet
reproduced them from source. Until that exists, folding them in would emit a
patch whose CJS stanza came from the current hand-edited bundle.
+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)}
@@ -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 {
@@ -738,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,
@@ -4186,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
@@ -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)
}
]
: []
+2 -1
View File
@@ -44,7 +44,7 @@
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.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": "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",
@@ -77,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
@@ -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,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 })
}
})
})
+83 -12
View File
@@ -18,6 +18,7 @@ import {
hasCodexTranscriptSubagents,
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
isNewTurnEvent,
markClaudeLeadTurnInterrupted,
markCodexLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
@@ -76,6 +77,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
@@ -107,6 +114,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. */
@@ -122,7 +131,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
}
@@ -437,6 +452,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
}
}
@@ -740,6 +756,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).
@@ -1279,9 +1300,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.
@@ -1306,7 +1353,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)
@@ -1436,7 +1486,10 @@ export class AgentHookServer {
if (!identity.inheritedFromActivePane) {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
const enriched = this.attachStatusTiming(boundaryAwarePayload, now)
const enriched = {
...this.attachStatusTiming(boundaryAwarePayload, now),
observation: this.stampObservation(boundaryAwarePayload, origin, now)
}
if (
typeof enriched.payload.turnCompletedAt === 'number' &&
Number.isFinite(enriched.payload.turnCompletedAt)
@@ -1827,6 +1880,7 @@ export class AgentHookServer {
this.runtimeObservedStatusPaneKeys.delete(key)
this.currentAuthorityObservations.delete(key)
this.promptSentDedupeByPaneKey.delete(key)
this.observations.forget(key)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
@@ -2166,14 +2220,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. */
@@ -2278,6 +2336,12 @@ 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 =
@@ -2519,6 +2583,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)
@@ -3160,6 +3229,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(
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildWslCodexAvailabilityArgs,
buildWslCodexIdentityArgs,
buildWslCodexIdentityProbe,
buildWslCodexLoginArgs
} from './wsl-codex-command'
@@ -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)
const command = buildWslCodexIdentityProbe('Ubuntu').args.at(-1)
expect(command).toMatch(/printf .*"\\\$resolved"/)
expect(command).toContain('exec "\\$resolved" --version')
expect(command).toMatch(/printf .*"\$resolved"/)
expect(command).toContain('exec "$resolved" --version')
})
})
+24 -11
View File
@@ -1,7 +1,8 @@
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
import {
buildWslCapturedLoginShellCommand,
buildWslExecArgs,
buildWslLoginShellCommand,
escapeWslShCommandForWindows,
quotePosixShell
} from '../../shared/wsl-login-shell-command'
@@ -13,7 +14,22 @@ export function buildWslCodexAvailabilityArgs(distro: string): string[] {
return buildWslCodexShellArgs(distro, command)
}
export function buildWslCodexIdentityArgs(distro: string): string[] {
export type WslCodexIdentityProbe = {
args: string[]
/** The `<path>\n<version>` payload, or null when the fence never appeared. */
readStdout: (stdout: string) => string | null
}
/**
* Probe the distro's Codex path and version.
*
* Why fenced: this reads the login shell's stdout positionally (path before the
* first newline, version after), and an interactive login shell prints the
* distro rc/motd to stdout ahead of it — which silently moves that split into
* the banner. The payload ends in `exec`, so no closing fence is ever written;
* `readStdout` returns everything after the opening one.
*/
export function buildWslCodexIdentityProbe(distro: string): WslCodexIdentityProbe {
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
@@ -23,7 +39,11 @@ export function buildWslCodexIdentityArgs(distro: string): string[] {
'printf \'%s\\n\' "$resolved"',
'exec "$resolved" --version'
].join('\n')
return buildWslCodexShellArgs(distro, command)
const captured = buildWslCapturedLoginShellCommand(command)
return {
args: buildWslExecArgs(distro, ['sh', '-c', captured.command]),
readStdout: captured.readStdout
}
}
export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string): string[] {
@@ -59,12 +79,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)])
}
+11 -3
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildWslCodexIdentityArgs } from '../codex-accounts/wsl-codex-command'
const { execFileSyncMock, resolveCodexCommandMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn(),
@@ -16,7 +16,15 @@ import { resolveCodexTrustGrantHost } from './codex-trust-grant-host'
beforeEach(() => {
execFileSyncMock.mockReset()
execFileSyncMock.mockReturnValue('/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n')
// Stand in for the guest shell: rc banner first, then the payload inside the
// command's own fence. The identity script execs, so no closing fence is written.
execFileSyncMock.mockImplementation((_command: string, args: string[]) => {
const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(String(args.at(-1)))?.[1] ?? ''
return (
'To run a command as administrator (user "root"), use "sudo <command>".\n\n' +
`__ORCA_WSL_CAPTURE_BEGIN_${nonce}__/home/alice/.local/bin/codex\ncodex-cli 1.2.3\n`
)
})
resolveCodexCommandMock.mockReset()
resolveCodexCommandMock.mockReturnValue(process.execPath)
})
@@ -60,7 +68,7 @@ describe('resolveCodexTrustGrantHost', () => {
expect(request.invocation.command).toBe('wsl.exe')
expect(execFileSyncMock).toHaveBeenCalledWith(
'wsl.exe',
buildWslCodexIdentityArgs('Ubuntu'),
expect.arrayContaining(['-d', 'Ubuntu', '--exec', 'sh', '-c']),
expect.objectContaining({ encoding: 'utf-8', timeout: 5_000, windowsHide: true })
)
expect(resolveCodexCommandMock).not.toHaveBeenCalled()
+9 -2
View File
@@ -3,7 +3,7 @@ import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import {
buildWslCodexAppServerArgs,
buildWslCodexIdentityArgs,
buildWslCodexIdentityProbe,
WSL_CODEX_AVAILABILITY_TIMEOUT_MS
} from '../codex-accounts/wsl-codex-command'
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
@@ -82,11 +82,18 @@ function buildWslCodexBinaryStamp(distro: string): CodexTrustGrantBinaryStamp |
try {
// Why: WSL PATH resolution happens inside the distro's login shell. The
// resolved path plus CLI version detects upgrades without assuming UNC access.
const output = execFileSync('wsl.exe', buildWslCodexIdentityArgs(distro), {
const probe = buildWslCodexIdentityProbe(distro)
const stdout = execFileSync('wsl.exe', probe.args, {
encoding: 'utf-8',
timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS,
windowsHide: true
})
// Why: the split below is positional, so login-shell rc output ahead of the
// payload would silently become the "path" and destabilize the stamp.
const output = probe.readStdout(stdout)
if (output === null) {
return null
}
const lineBreak = output.indexOf('\n')
const path = lineBreak === -1 ? '' : output.slice(0, lineBreak).trim()
const version = lineBreak === -1 ? '' : output.slice(lineBreak + 1).trim()
@@ -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.
@@ -146,7 +146,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
['--exec', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@@ -177,7 +177,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining("cd '/mnt/c/repo'")],
['-d', 'Debian', '--exec', 'sh', '-c', expect.stringContaining("cd '/mnt/c/repo'")],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@@ -205,7 +205,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@@ -233,7 +233,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
expect.objectContaining({
env: expect.not.objectContaining({
CODEX_HOME: expect.anything(),
@@ -318,7 +318,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
expect.objectContaining({
env: expect.objectContaining({
CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home',
@@ -352,7 +352,7 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' })
})
@@ -470,7 +470,7 @@ describe('createPtySubprocess', () => {
)
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`)],
['-d', 'Ubuntu', '--exec', 'sh', '-c', expect.stringContaining(`cd '${cwd}'`)],
expect.objectContaining({ cwd: expect.any(String) })
)
}
+7
View File
@@ -37,6 +37,7 @@ import {
import { isPwshAvailable } from '../pwsh'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { dropInheritedOrcaFishHistory } from '../fish-history-session'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
@@ -654,6 +655,12 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise<S
}
// Why: the daemon can inherit the pane identity of the terminal that launched `pn dev`; each PTY must opt into its own.
removeUnspecifiedPaneIdentityEnv(env, opts.env)
// Why: the daemon inherits an exported `fish_history` from the fish pane that
// launched Orca; only a session this spawn asked for may reach the shell, or
// panes write into the launching worktree's history file (STA-4682).
if (opts.env?.fish_history === undefined) {
dropInheritedOrcaFishHistory(env)
}
removeInheritedDevAgentHookEndpoint(env, opts.env)
removeInheritedElectronRunAsNode(env)
removeAppImageRuntimeEnv(env)
+9 -43
View File
@@ -6,22 +6,12 @@ import {
getPowerShellOsc133Bootstrap,
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import {
getFishCodexShellLaunchPreflight,
getPosixCodexShellLaunchPreflight
} from '../pty/codex-shell-launch-preflight'
import {
getFishShellReadyInitCommand,
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
getZshShellReadyMarkerRegistrationBlock,
getZshStartupFileSourceBlock,
ZSH_HISTFILE_RESTORE_BLOCK
} from '../shell-templates'
import { getFishCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import { getFishShellReadyInitCommand } from '../shell-templates'
import { buildZshStartupWrapperFiles } from '../zsh-startup-wrapper-builder'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
import { getDaemonZshShellReadyRcfileContent } from './daemon-zsh-shell-ready-rcfile'
import { getDaemonZshWrapperSpec } from './daemon-zsh-shell-ready-wrapper-spec'
const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH'
@@ -102,38 +92,14 @@ function ensureShellReadyWrappers(): void {
const zshDir = join(root, 'zsh')
const bashDir = join(root, 'bash')
const zshEnv = getZshEnvTemplate(zshDir, 'daemon')
const zshProfile = `# Orca daemon zsh shell-ready wrapper
${getZshStartupFileSourceBlock({ fileName: '.zprofile' })}
`
const zshRc = getDaemonZshShellReadyRcfileContent()
const zshLogin = `# Orca daemon zsh shell-ready wrapper
${getZshStartupFileSourceBlock({ fileName: '.zlogin', interactiveOnly: 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"
}
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -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()}
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
${getZshFinalZdotdirRestoreBlock()}
`
const zsh = buildZshStartupWrapperFiles(getDaemonZshWrapperSpec(zshDir))
const bashRc = getDaemonBashShellReadyRcfileContent()
const files = [
[join(zshDir, '.zshenv'), zshEnv],
[join(zshDir, '.zprofile'), zshProfile],
[join(zshDir, '.zshrc'), zshRc],
[join(zshDir, '.zlogin'), zshLogin],
[join(zshDir, '.zshenv'), zsh.zshenv],
[join(zshDir, '.zprofile'), zsh.zprofile],
[join(zshDir, '.zshrc'), zsh.zshrc],
[join(zshDir, '.zlogin'), zsh.zlogin],
[join(bashDir, 'rcfile'), bashRc]
] as const
+3
View File
@@ -400,6 +400,9 @@ export const NOTIFY_PREFIX = 'notify_'
// live in daemon-errors.ts (this file is capped for wire-shape declarations).
export {
TerminalAttachCanceledError,
DaemonConnectionLostError,
DaemonProtocolError,
DaemonRequestTimeoutError,
DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
SessionNotFoundError
} from './daemon-errors'
@@ -33,10 +33,25 @@ describe('classifySubprocessCommand', () => {
expect(classifySubprocessCommand('gh', ['api', 'rate_limit'])).toBe('gh api')
})
it('unwraps wsl.exe-routed commands', () => {
it.each([
// Orca's own spawns use --exec; `--`/`-e` still arrive from foreign wsl.exe processes.
['--exec', '--exec'],
['-e', '-e'],
['--', '--']
])('unwraps wsl.exe-routed commands past %s', (_label, separator) => {
expect(
classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu', '--', 'git', 'status', '--porcelain'])
classifySubprocessCommand('wsl.exe', [
'-d',
'Ubuntu',
separator,
'git',
'status',
'--porcelain'
])
).toBe('git status')
})
it('reports bare wsl.exe with no guest command as wsl', () => {
expect(classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu'])).toBe('wsl')
})
@@ -38,9 +38,11 @@ export function classifySubprocessCommand(command: string, args: readonly string
let binary = binaryName(command)
const rest = [...args]
if (binary === 'wsl') {
// Orca spawns guest commands with `--exec`; `--`/`-e` still appear on
// wsl.exe processes started outside Orca, so unwrap either separator.
while (rest.length > 0) {
const arg = rest.shift()
if (arg === '--') {
if (arg === '--' || arg === '--exec' || arg === '-e') {
break
}
}

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