mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+24
-24
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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',
|
||||
() => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {}',
|
||||
')',
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(' ')}`
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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) })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user